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
+1530
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
/* ============================================================
* audio.js — tiny WebAudio synth SFX (no asset files needed)
* ============================================================ */
'use strict';
const AudioSys = {
ctx: null,
muted: false,
/** Lazily create/resume the context (must follow a user gesture). */
ensure() {
if (this.muted) return null;
try {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
this.ctx = new AC();
}
if (this.ctx.state === 'suspended') this.ctx.resume();
return this.ctx;
} catch (e) { return null; }
},
tone(freq, dur, type = 'sine', vol = 0.12, when = 0, glideTo = 0) {
const c = this.ensure();
if (!c) return;
const t = c.currentTime + when;
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if (glideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30, glideTo), t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(c.destination);
o.start(t); o.stop(t + dur + 0.03);
},
sfx(name) {
switch (name) {
case 'click': this.tone(650, .05, 'square', .04); break;
case 'toast': this.tone(520, .07, 'sine', .06); this.tone(780, .06, 'sine', .04, .05); break;
case 'error': this.tone(170, .16, 'sawtooth', .09); this.tone(120, .18, 'sawtooth', .08, .08); break;
case 'place': this.tone(340, .07, 'triangle', .12); this.tone(240, .09, 'triangle', .10, .06); break;
case 'sell': [880, 1175, 1568].forEach((f, i) => this.tone(f, .09, 'square', .06, i * .06)); break;
case 'chime': this.tone(660, .12, 'sine', .09); this.tone(990, .14, 'sine', .07, .09); break;
case 'level': this.tone(700, .08, 'square', .06); this.tone(1050, .12, 'square', .06, .07); break;
case 'fanfare':
[[523, 0], [659, .11], [784, .22], [1047, .33]].forEach(([f, d]) => this.tone(f, .16, 'triangle', .1, d));
this.tone(1047, .4, 'sine', .07, .5); break;
case 'bill': this.tone(330, .1, 'square', .07); this.tone(330, .12, 'square', .07, .14); break;
case 'horn': this.tone(290, .28, 'sawtooth', .08); this.tone(290, .34, 'sawtooth', .08, .32); break;
case 'thud': this.tone(120, .22, 'sine', .16, 0, 55); break;
case 'splash': this.tone(500, .1, 'sine', .08, 0, 180); this.tone(260, .16, 'sine', .08, .05, 90); break;
case 'toll': this.tone(98, 1.4, 'sine', .2); this.tone(196, 1.2, 'sine', .08, .02); break;
case 'kiss': this.tone(900, .06, 'sine', .07); this.tone(1300, .08, 'sine', .05, .06); break;
default: break;
}
},
};
/* unlock audio on first gesture */
window.addEventListener('pointerdown', () => AudioSys.ensure(), { once: true });
+97
View File
@@ -0,0 +1,97 @@
/* ============================================================
* core.js — utilities, isometric math, event bus
* ============================================================ */
'use strict';
const TW = 64, TH = 32; // iso tile width/height
const WALL_H = 46; // wall pixel height
const LOT_W = 34, LOT_H = 34; // lot size in tiles
/* ---------- tiny helpers ---------- */
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dist2(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }
let __uid = 1;
function uid() { return __uid++; }
function rand(a, b) { return a + Math.random() * (b - a); }
function randi(a, b) { return Math.floor(rand(a, b + 1)); }
function choice(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function chance(p) { return Math.random() < p; }
/** Deterministic-ish name pools for CAS random & visitors */
const FIRST_NAMES_M = ['Mortimer','Bob','Michael','Gunther','Mickey','Goopy','Skip','Dustin','Dirk','Romeo','Puck','Tybalt','Mercutio','Patrizio','Don','Victor'];
const FIRST_NAMES_F = ['Bella','Cassandra','Dina','Nina','Cornelia','Riley','Kaylynn','Jenny','Brandi','Angela','Lilith','Hermia','Juliet','Isabel','Kayla','Molly'];
const LAST_NAMES = ['Goth','Bachelor','Dreamer','Pleasant','Caliente','Broke','Oldie','Rosalie','Monty','Capp','Summerdream','Smith','Newbie','Grunt','Jacquet','Freshe','Tricou','Moore','Oasis','Lothario'];
/* ---------- isometric transforms ----------
* World coordinates: tile floats (x,y). Screen coords pre-camera. */
function isoToScreen(x, y) {
return [ (x - y) * TW / 2, (x + y) * TH / 2 ];
}
/** screen (pre-camera) -> world tile float */
function screenToIso(sx, sy) {
const x = (sx / (TW / 2) + sy / (TH / 2)) / 2;
const y = (sy / (TH / 2) - sx / (TW / 2)) / 2;
return [x, y];
}
/** camera helpers attached to game object G */
function worldToPx(wx, wy) {
const [sx, sy] = isoToScreen(wx, wy);
return [ sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y ];
}
function pxToWorld(px, py) {
const sx = (px - G.cam.x) / G.cam.zoom, sy = (py - G.cam.y) / G.cam.zoom;
return screenToIso(sx, sy);
}
/* Facing directions: 0=S(+y), 1=W(-x), 2=N(-y), 3=E(+x) — matches sprite art */
const DIRS = [
{ dx: 0, dy: 1 }, // S
{ dx: -1, dy: 0 }, // W
{ dx: 0, dy: -1 }, // N
{ dx: 1, dy: 0 }, // E
];
function dirFromDelta(dx, dy) {
if (Math.abs(dx) >= Math.abs(dy)) return dx > 0 ? 3 : 1;
return dy > 0 ? 0 : 2;
}
/* ---------- colors ---------- */
const SKINS = ['#f6d7c4','#eeb98f','#c98a5e','#8d5a3b'];
const HAIRS = ['#2a1c12','#5a3a1e','#a56a2f','#d9b24a','#b23a2a','#777777','#101010','#e8e0cf'];
const SHIRTS = ['#d94a4a','#4a72d9','#43b05a','#e8a33d','#8a52c9','#3ab5b0','#e06aa8','#556077','#f0f0f0','#22262e'];
const PANTS = ['#2e3542','#3d5a99','#6b4226','#7a7f88','#274832','#803040'];
/* ---------- event bus ---------- */
const Bus = {
map: {},
on(ev, fn) { (this.map[ev] ||= []).push(fn); },
emit(ev, data) { (this.map[ev] || []).forEach(fn => fn(data)); },
};
/* ---------- money formatting ---------- */
function fmtMoney(n) { return '§' + Math.round(n).toLocaleString('en-US'); }
/** trace a rounded-rectangle path (caller fills/strokes) */
function roundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/* ---------- time helpers ---------- */
function hourTo12(h) {
h = ((h % 24) + 24) % 24;
const ampm = h < 12 ? 'AM' : 'PM';
let hh = h % 12; if (hh === 0) hh = 12;
return hh + ':' + String(G.time.min).padStart(2, '0') + ' ' + ampm;
}
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
/* ---------- deep merge for save/load safety ---------- */
function isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); }
+291
View File
@@ -0,0 +1,291 @@
/* ============================================================
* data.js — object catalog, careers, skills, socials, need meta
* ============================================================ */
'use strict';
/* ---------------- NEEDS ---------------- */
const NEEDS = {
hunger: { label:'Hunger', icon:'🍔', color:'#e8a33d', decay: -7 },
energy: { label:'Energy', icon:'💤', color:'#7f6fd0', decay: -4 },
bladder: { label:'Bladder', icon:'🚽', color:'#4fa3d9', decay: -6 },
hygiene: { label:'Hygiene', icon:'🚿', color:'#5ad0c8', decay: -4 },
fun: { label:'Fun', icon:'🎉', color:'#e05aa8', decay: -5 },
social: { label:'Social', icon:'💬', color:'#63c15a', decay: -5 },
comfort: { label:'Comfort', icon:'🛋️', color:'#c9a24a', decay: -5 },
room: { label:'Room', icon:'🌸', color:'#9b7fd0', decay: 0 }, // computed from environment
};
/* ---------------- SKILLS ---------------- */
const SKILLS = [
{ id:'cooking', name:'Cooking', icon:'🍳' },
{ id:'mechanical', name:'Mechanical', icon:'🔧' },
{ id:'charisma', name:'Charisma', icon:'💬' },
{ id:'body', name:'Body', icon:'💪' },
{ id:'logic', name:'Logic', icon:'🧠' },
{ id:'creativity', name:'Creativity', icon:'🎨' },
{ id:'cleaning', name:'Cleaning', icon:'🧹' },
];
/* ---------------- PERSONALITY TRAITS ---------------- */
const TRAITS = ['neat','outgoing','active','playful','nice'];
/* ---------------- CAREERS ----------------
* 10 ranks each; generated salaries; skill requirements ramp. */
function makeCareer(id, icon, title1, titles, basePay, startHour, endHour, skillKey) {
const ranks = [];
for (let i = 0; i < 10; i++) {
const req = {};
if (skillKey && i >= 1) req[skillKey] = Math.min(10, Math.ceil(i * 1.1));
if (i >= 5 && skillKey !== 'charisma') req.charisma = Math.ceil((i - 4) * 0.8);
ranks.push({
title: titles[i],
salary: Math.round(basePay * Math.pow(1.42, i)),
hours: [startHour, endHour],
offDays: (id === 'criminal') ? [2, 4] : [5, 6], // index of DAY_NAMES; criminal works weekends
req,
carpoolHour: startHour - 1,
});
}
return { id, icon, name: titles[0], trackName: title1 + ' Track', ranks };
}
const CAREERS = [
makeCareer('business', '💼', 'Business',
['Mailroom Tech','Trainee','Junior Executive','Executive','Assistant Manager','Manager','Vice President','President','CEO','Business Tycoon'],
154, 9, 16, 'charisma'),
makeCareer('culinary', '🍳', 'Culinary',
['Dish Washer','Fast Food Shift Manager','Drive-Thru Clerk','Counter Person','Waiter','Head Waiter','Sous-Chef','Executive Chef','Celebrity Chef','Five-Star Chef'],
126, 14, 21, 'cooking'),
makeCareer('science', '🔬', 'Science',
['Test Subject','Lab Assistant','Field Researcher','Science Teacher','Lab Tech','Research Assistant','Project Leader','Inventor','Scientist','Mad Scientist'],
140, 9, 15, 'logic'),
makeCareer('medicine', '🩺', 'Medicine',
['Emergency Medical Technician','Paramedic','Nurse','Orderly','Intern','Resident','General Practitioner','Specialist','Surgeon','Chief of Staff'],
168, 8, 17, 'mechanical'),
makeCareer('athletic', '🏆', 'Athletic',
['Locker Room Attendant','Team Mascot','Waterperson','Towel Boy','Equipment Manager','Team Physician','Coach','Mascot Manager','Most Valuable Player','Hall of Famer'],
132, 12, 19, 'body'),
makeCareer('criminal', '🕵️', 'Criminal',
['Con Artist','Pickpocket','Bookmaker','Cutpurse','Safecracker','Bank Robber','Cat Burglar','Safe Cracker','Getaway Driver','Crime Lord'],
150, 21, 4, 'body'),
makeCareer('military', '🎖️', 'Military',
['Recruit','Private','Corporal','Sergeant','Junior Officer','Lieutenant','Captain','Major','Colonel','Astronaut'],
155, 7, 14, 'mechanical'),
makeCareer('art', '🎭', 'Arts',
['Subway Performer','Coffee Shop Singer','Portrait Painter','Street Mime','Wedding Singer','Commercial Actor','Supporting Role','Lead Role','Famous Star','Icon'],
118, 13, 20, 'creativity'),
];
const ASPIRATIONS = {
fortune: { name:'Fortune', icon:'💰', desc:'Wants riches and promotions.' },
knowledge: { name:'Knowledge', icon:'📚', desc:'Wants maxed skills.' },
popularity: { name:'Popularity', icon:'🎊', desc:'Wants many friends.' },
family: { name:'Family', icon:'🏡', desc:'Wants a cozy home life.' },
romance: { name:'Romance', icon:'💘', desc:'Wants love and charm.' },
};
/* ---------------- WHIMS (short-term wants) ---------------- */const WHIMS = {
fortune: [
{ id:'promote', icon:'⭐', label:'Get promoted', ev:'promotion', reward:130 },
{ id:'earn', icon:'💰', label:'Earn §400 on the side', ev:'earn', amount:400, reward:80 },
{ id:'job', icon:'📋', label:'Get hired', ev:'job', reward:60 },
],
knowledge: [
{ id:'maxskill', icon:'🧠', label:'Max out a skill (10)', ev:'skill', level:10, reward:150 },
{ id:'anylevel', icon:'📈', label:'Gain any skill level', ev:'skill', reward:50 },
{ id:'chessy', icon:'♟️', label:'Play chess', ev:'social', match:'chess', reward:40 },
],
popularity: [
{ id:'friend', icon:'🤝', label:'Make a new friend', ev:'friend', reward:120 },
{ id:'mixer', icon:'💬', label:'Socialize 5 times', ev:'social', count:5, reward:70 },
{ id:'host', icon:'👋', label:'Invite a neighbor over', ev:'visitor', reward:45 },
],
family: [
{ id:'meal', icon:'🍲', label:'Cook a nice meal', ev:'meal', reward:60 },
{ id:'rested', icon:'😴', label:'Wake up fully rested', ev:'rested', reward:60 },
{ id:'tidy', icon:'✨', label:'Clean something filthy', ev:'cleaned', reward:45 },
],
romance: [
{ id:'flirt', icon:'💗', label:'Flirt with someone', ev:'social', match:'flirt', reward:85 },
{ id:'hug', icon:'🤗', label:'Share a hug', ev:'social', match:'hug', reward:50 },
{ id:'love', icon:'💞', label:'Fall in love', ev:'love', reward:200 },
],
};
/* ---------------- SOCIAL INTERACTIONS ---------------- */
const SOCIALS = [
{ id:'talk', label:'Talk', icon:'💬', str:+4, ltr:+1.5, dur:10, minRel:-100 },
{ id:'joke', label:'Joke', icon:'😄', str:+6, ltr:+2, dur:8, playfulBoost:true },
{ id:'compliment', label:'Compliment', icon:'🌹', str:+7, ltr:+2.5, dur:6, niceBoost:true },
{ id:'hug', label:'Hug', icon:'🤗', str:+9, ltr:+3.5, dur:5, minRel:25 },
{ id:'flirt', label:'Flirt', icon:'💗', str:+11, ltr:+5, dur:8, minRel:45, romanceOnly:false },
{ id:'dance', label:'Dance Together', icon:'🕺', str:+8, ltr:+3, dur:20 },
{ id:'insult', label:'Insult', icon:'😠', str:-12, ltr:-6, dur:5 },
{ id:'argue', label:'Argue', icon:'🗯️', str:-18, ltr:-9, dur:8, minRelMax:0 },
];
/* ---------------- OBJECT CATALOG ----------------
* shape keys are painted procedurally in render.js
* interactions: generic runner in ai.js — dur = minutes, fx = need points/min while using */
/* --- meals by cooking skill (needs groceries from the fridge stock) --- */
const MEALS = [
{ id:'quick', name:'Instant Noodles', skill:0, hunger:22, fun:2, emoji:'🍜' },
{ id:'spaghetti', name:'Spaghetti', skill:2, hunger:34, fun:8, emoji:'🍝' },
{ id:'roast', name:'Sunday Roast', skill:4, hunger:46, fun:16, emoji:'🍗' },
{ id:'gourmet', name:'Gourmet Lobster', skill:6, hunger:58, fun:28, emoji:'🦞' },
];
/* --- career chance cards (picked at random while at work) --- */
const CHANCE_CARDS = [
{ q:'Your boss needs someone to stay late and finish a report.',
a:[{ label:'Stay late', icon:'🌙', fx:{ perf:8, energy:-18 }, say:'💼' },
{ label:'Go home', icon:'🏠', fx:{ perf:-3 }, say:'😐' }] },
{ q:'A coworker asks you to cover their mistake in front of the client.',
a:[{ label:'Cover for them', icon:'🤝', fx:{ perf:5 }, say:'😇' },
{ label:'Tell the truth', icon:'📢', fx:{ perf:2, bonus:150 }, say:'😎' }] },
{ q:'You found a wallet in the parking lot with §200 inside.',
a:[{ label:'Return it', icon:'🙌', fx:{ perf:6 }, say:'😊' },
{ label:'Keep it', icon:'💰', fx:{ money:200, perf:-7 }, say:'🤑' }] },
{ q:'Upper management is watching today. Impress them?',
a:[{ label:'Give a bold presentation', icon:'📊', fx:{ dice:.55, win:{ perf:14 }, lose:{ perf:-10 } } },
{ label:'Keep a low profile', icon:'🙈', fx:{ perf:1 } }] },
{ q:'The office espresso machine broke. Fix it yourself?',
a:[{ label:'Fix it', icon:'🔧', fx:{ dice:.7, win:{ skill:'mechanical', amt:1, perf:4 }, lose:{ perf:-5, say:'⚡' } } },
{ label:'Drink tea instead', icon:'🍵', fx:{ fun:10 } }] },
{ q:'A rival offers you insider tips for a quick promotion.',
a:[{ label:'Take the tips', icon:'🕵️', fx:{ dice:.6, win:{ perf:12 }, lose:{ perf:-12, firedRisk:.06 } } },
{ label:'Refuse', icon:'🙅', fx:{ perf:3, say:'🙂' } }] },
{ q:'Charity gala tonight — attend or rest?',
a:[{ label:'Attend the gala', icon:'🥂', fx:{ energy:-15, perf:7, social:20 }, say:'✨' },
{ label:'Sleep early', icon:'😴', fx:{ energy:25, perf:-2 } }] },
];
const OBJECTS = {
/* --- seating --- */
chair: { id:'chair', name:'Chair', emoji:'🪑', price:85, w:1, h:1, cat:'seating', shape:'chair', rot:true, sit:true,
env:2, interactions:[{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.35}, pose:'sit' }] },
stool: { id:'stool', name:'Stool', emoji:'🟫', price:50, w:1, h:1, cat:'seating', shape:'stool', rot:true, sit:true,
env:1, interactions:[{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.28}, pose:'sit' }] },
sofa: { id:'sofa', name:'Sofa', emoji:'🛋️', price:450, w:1, h:3, cat:'seating', shape:'sofa', rot:true, sit:true,
env:4, comfortSeat:1, interactions:[
{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.45}, pose:'sit' },
{ id:'nap', label:'Nap', icon:'💤', dur:120, fx:{energy:.22, comfort:.3}, pose:'sit' }] },
loveseat: { id:'loveseat', name:'Loveseat', emoji:'💺', price:350, w:1, h:2, cat:'seating', shape:'loveseat', rot:true, sit:true,
env:3, interactions:[{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.4}, pose:'sit' }] },
/* --- tables --- */
table: { id:'table', name:'Table', emoji:'🍽️', price:250, w:1, h:1, cat:'tables', shape:'table', rot:true, env:2, eatSpot:true, interactions:[] },
coffeeTable: { id:'coffeeTable', name:'Coffee Table', emoji:'☕', price:120, w:2, h:1, cat:'tables', shape:'coffeeTable', rot:true, env:2, interactions:[] },
desk: { id:'desk', name:'Desk', emoji:'🗄️', price:180, w:2, h:1, cat:'tables', shape:'desk', rot:true, env:2, interactions:[] },
/* --- beds --- */
bedSingle: { id:'bedSingle', name:'Single Bed', emoji:'🛏️', price:400, w:1, h:2, cat:'beds', shape:'bedSingle', rot:true,
env:3, sleep:true, interactions:[
{ id:'sleep', label:'Sleep', icon:'😴', special:'sleep', pose:'lie' },
{ id:'nap', label:'Nap 2h', icon:'💤', special:'sleep', nap:true, pose:'lie' }] },
bedDouble: { id:'bedDouble', name:'Double Bed', emoji:'🛌', price:900, w:2, h:2, cat:'beds', shape:'bedDouble', rot:true,
env:5, sleep:true, love:true, interactions:[
{ id:'sleep', label:'Sleep', icon:'😴', special:'sleep', pose:'lie' },
{ id:'nap', label:'Nap 2h', icon:'💤', special:'sleep', nap:true, pose:'lie' },
{ id:'tryBaby', label:'Try for Baby', icon:'👶', special:'tryBaby', pose:'lie', dur:25 }] },
/* --- plumbing --- */
toilet: { id:'toilet', name:'Toilet', emoji:'🚽', price:300, w:1, h:1, cat:'bath', shape:'toilet', rot:true, env:-2,
interactions:[
{ id:'pee', label:'Use Toilet', icon:'🚽', dur:10, fx:{bladder:9}, pose:'sit' },
{ id:'clean',label:'Clean Toilet', icon:'🧽', special:'clean', requiresDirty:true }] },
shower: { id:'shower', name:'Shower', emoji:'🚿', price:500, w:1, h:1, cat:'bath', shape:'shower', rot:true, env:1,
interactions:[{ id:'shower', label:'Take Shower', icon:'🚿', dur:22, fx:{hygiene:4.4, fun:.15, energy:.05}, pose:'stand' }] },
bathtub:{ id:'bathtub', name:'Bathtub', emoji:'🛁', price:600, w:1, h:2, cat:'bath', shape:'bathtub', rot:true, env:3,
interactions:[{ id:'bathe', label:'Take Bath', icon:'🛁', dur:40, fx:{hygiene:2.4, comfort:.5, fun:.3}, pose:'lie' }] },
sink: { id:'sink', name:'Sink', emoji:'🚰', price:150, w:1, h:1, cat:'bath', shape:'sink', rot:true, env:1,
interactions:[{ id:'wash', label:'Wash Up', icon:'🧼', dur:5, fx:{hygiene:3.5}, pose:'stand' }] },
mirror: { id:'mirror', name:'Wall Mirror', emoji:'🪞', price:175, w:1, h:1, cat:'bath', shape:'mirror', wallObj:true, rot:true, env:2,
interactions:[{ id:'practice', label:'Practice Charisma', icon:'💬', dur:60, fx:{social:.1, fun:.08}, skill:{id:'charisma', rate:.028}, pose:'stand' }] },
/* --- kitchen --- */
fridge: { id:'fridge', name:'Fridge', emoji:'🧊', price:600, w:1, h:1, cat:'kitchen', shape:'fridge', rot:true, env:1,
interactions:[
{ id:'meal', label:'Have a Meal', icon:'🍲', special:'cookMeal', pose:'stand' },
{ id:'snack', label:'Grab a Snack', icon:'🍎', dur:8, fx:{hunger:2.6}, pose:'stand' },
{ id:'groceries', label:'Order Groceries (§60)', icon:'🛍️', special:'groceries', cost:60, pose:'stand' }] },
stove: { id:'stove', name:'Stove', emoji:'🔥', price:500, w:1, h:1, cat:'kitchen', shape:'stove', rot:true, env:1, interactions:[] },
counter:{ id:'counter', name:'Counter', emoji:'🧾', price:140, w:1, h:1, cat:'kitchen', shape:'counter', rot:true, env:1, prepTarget:true, interactions:[] },
trash: { id:'trash', name:'Trash Can', emoji:'🗑️', price:60, w:1, h:1, cat:'kitchen', shape:'trash', rot:true, env:-3,
interactions:[{ id:'empty', label:'Empty Trash', icon:'🗑️', special:'emptyTrash', requiresFull:true }] },
/* --- electronics --- */
tv: { id:'tv', name:'Television', emoji:'📺', price:800, w:2, h:1, cat:'electronics', shape:'tv', rot:true, env:3, fragile:.0035,
watchTarget:true, interactions:[{ id:'watch', label:'Watch TV', icon:'📺', dur:90, fx:{fun:.55, comfort:.06, social:.04}, pose:'sitOrStand' }] },
stereo: { id:'stereo', name:'Stereo', emoji:'🔊', price:550, w:1, h:1, cat:'electronics', shape:'stereo', rot:true, env:2, fragile:.0025,
interactions:[{ id:'dance', label:'Dance!', icon:'💃', dur:45, fx:{fun:.75, social:.05}, skill:{id:'body', rate:.006}, pose:'stand', anim:'dance' }] },
computer:{ id:'computer', name:'Computer', emoji:'🖥️', price:2100, w:1, h:1, cat:'electronics', shape:'computer', rot:true, env:2, fragile:.0018,
interactions:[
{ id:'findJob', label:'Find a Job…', icon:'📰', special:'findJob', pose:'sit' },
{ id:'games', label:'Play Games', icon:'🎮', dur:60, fx:{fun:.65}, skill:{id:'logic', rate:.008}, pose:'sit' },
{ id:'write', label:'Write Novel', icon:'✍️', special:'writeNovel', pose:'sit', dur:90 }] },
phone: { id:'phone', name:'Telephone', emoji:'☎️', price:90, w:1, h:1, cat:'electronics', shape:'phone', rot:true, env:0,
interactions:[
{ id:'chatPhone', label:'Chat With Friend', icon:'📞', dur:30, fx:{social:1.4, fun:.15}, pose:'stand' },
{ id:'invite', label:'Invite Neighbor Over', icon:'👋', special:'inviteOver', pose:'stand' },
{ id:'party', label:'Throw Party Tonight 🎉', icon:'🥳', special:'throwParty', pose:'stand' },
{ id:'orderPizza',label:'Order Pizza (§40)', icon:'🍕', special:'pizza', cost:40, pose:'stand' }] },
/* --- skill / fun objects --- */
bookshelf:{ id:'bookshelf', name:'Bookshelf', emoji:'📚', price:250, w:1, h:1, cat:'study', shape:'bookshelf', rot:true, env:3,
interactions:[
{ id:'readLogic', label:'Study Logic', icon:'🧠', dur:60, fx:{fun:.05, energy:.02}, skill:{id:'logic', rate:.03}, pose:'stand' },
{ id:'readCook', label:'Study Cooking', icon:'🍳', dur:60, fx:{fun:.08}, skill:{id:'cooking', rate:.03}, pose:'stand' },
{ id:'readMech', label:'Study Mechanical', icon:'🔧', dur:60, fx:{fun:.05}, skill:{id:'mechanical', rate:.03}, pose:'stand' },
{ id:'readFun', label:'Read for Fun', icon:'📖', dur:60, fx:{fun:.55}, pose:'stand' }] },
easel: { id:'easel', name:'Easel', emoji:'🖼️', price:350, w:1, h:1, cat:'study', shape:'easel', rot:true, env:2,
interactions:[
{ id:'paint', label:'Paint', icon:'🎨', dur:90, fx:{fun:.4, energy:-.05}, skill:{id:'creativity', rate:.033}, special:'paint', pose:'stand' }] },
treadmill:{ id:'treadmill', name:'Treadmill', emoji:'🏃', price:1200, w:1, h:1, cat:'study', shape:'treadmill', rot:true, env:1, fragile:.0022,
interactions:[{ id:'workout', label:'Work Out', icon:'💪', dur:60, fx:{fun:-.05, hygiene:-.35, energy:-.12}, skill:{id:'body', rate:.033}, pose:'stand', anim:'exercise' }] },
piano: { id:'piano', name:'Piano', emoji:'🎹', price:1300, w:2, h:1, cat:'study', shape:'piano', rot:true, env:4,
interactions:[{ id:'playPiano', label:'Play Piano', icon:'🎹', dur:60, fx:{fun:.45, social:.05}, skill:{id:'creativity', rate:.026}, pose:'sit' }] },
chessboard: { id:'chessboard', name:'Chess Table', emoji:'♟️', price:450, w:1, h:1, cat:'study', shape:'chessboard', rot:true, env:3,
interactions:[
{ id:'chess', label:'Play Chess', icon:'♟️', dur:60, fx:{fun:.4, logic:.0}, skill:{id:'logic', rate:.028}, pose:'sit' }] },
/* --- decor --- */
plant: { id:'plant', name:'Potted Plant', emoji:'🪴', price:120, w:1, h:1, cat:'decor', shape:'plant', rot:false, env:4 },
lamp: { id:'lamp', name:'Floor Lamp', emoji:'💡', price:75, w:1, h:1, cat:'decor', shape:'lamp', rot:false, env:2, light:70 },
painting:{ id:'painting', name:'Painting', emoji:'🏞️', price:200, w:1, h:1, cat:'decor', shape:'painting', wallObj:true, rot:true, env:5 },
fountain:{ id:'fountain', name:'Fountain', emoji:'⛲', price:2500, w:2, h:2, cat:'decor', shape:'fountain', rot:false, env:8, light:40 },
easel: { id:'easel', name:'Easel', emoji:'🎨', price:450, w:1, h:1, cat:'decor', shape:'easel', rot:true, env:4,
interactions:[
{ id:'paint', label:'Paint a Canvas', icon:'🖌️', special:'paint', pose:'stand', dur:90 },
{ id:'sellArt', label:'Sell Paintings', icon:'💵', special:'sellArt', pose:'stand', dur:10 }] },
gravestone:{ id:'gravestone', name:'Gravestone', emoji:'🪦', price:0, w:1, h:1, cat:'hidden', shape:'gravestone', rot:false, env:-6 },
crib: { id:'crib', name:'Crib', emoji:'🧸', price:350, w:1, h:1, cat:'kids', shape:'crib', rot:true, env:3, sleep:true,
interactions:[
{ id:'sleep', label:'Baby Nap', icon:'😴', special:'sleep', nap:true, pose:'lie', babyOnly:true }] },
toybox: { id:'toybox', name:'Toy Box', emoji:'🪀', price:180, w:1, h:1, cat:'kids', shape:'toybox', rot:false, env:4,
interactions:[{ id:'playToys', label:'Play with Toys', icon:'🧸', dur:45, fx:{fun:.7}, pose:'stand', childOnly:true }] },
};
/* ---------------- BUY CATEGORIES ---------------- */
const BUY_CATS = [
{ id:'seating', label:'Seating', icon:'🛋️' },
{ id:'tables', label:'Tables', icon:'🍽️' },
{ id:'beds', label:'Beds', icon:'🛏️' },
{ id:'bath', label:'Bathroom', icon:'🚿' },
{ id:'kitchen', label:'Kitchen', icon:'🍳' },
{ id:'electronics', label:'Electronics', icon:'📺' },
{ id:'study', label:'Skill & Fun', icon:'📚' },
{ id:'decor', label:'Decor', icon:'🪴' },
{ id:'kids', label:'Kids', icon:'🧸' },
];
/* ---------------- FLOOR STYLES & WALL STYLES ---------------- */
const FLOORS = [
{ id:'grass', c1:'#69a84f', c2:'#5f9c47', outdoor:true },
{ id:'wood', c1:'#b98a52', c2:'#a87b46' },
{ id:'tile', c1:'#dfe6ea', c2:'#cdd6dc' },
{ id:'carpet', c1:'#b0576a', c2:'#a34e60' },
{ id:'stone', c1:'#9aa2ab', c2:'#8b939c' },
{ id:'darkwood', c1:'#7a5636', c2:'#6c4a2e' },
];
const WALL_COLORS = ['#efe6d4','#d9cdb8','#bcd3e8','#d8bfcf','#c9dfc0','#e8d2a4','#b9aabf'];
+377
View File
@@ -0,0 +1,377 @@
/* ============================================================
* hood.js — The Neighborhood
* · AI households with persistent sims living around the map
* · They visit, stroll by, remember every chat (rel memory)
* · Full-screen neighborhood map view (key N) with family cards
* ============================================================ */
const ROOF_COLORS = ['#b5432e', '#3e6fa8', '#4a8a4a', '#a87f2e', '#7d4aa8', '#2e8a8a', '#a82e5c'];
function genNeighborhood() {
const surnames = [...LAST_NAMES].sort(() => Math.random() - .5);
const spots = [
{ gx: -1.55, gy: -.62 }, { gx: 1.55, gy: -.62 },
{ gx: -1.85, gy: .45 }, { gx: 1.85, gy: .45 },
{ gx: -.95, gy: 1.15 }, { gx: .95, gy: 1.15 },
];
const lots = [];
for (let i = 0; i < 6; i++) {
const surname = surnames[i];
const nAdults = randi(1, 2) + (chance(.35) ? 1 : 0);
const family = [];
for (let j = 0; j < nAdults; j++) {
const d = randomSimData(chance(.5) ? 'f' : 'm');
d.name = d.name.split(' ')[0] + ' ' + surname;
family.push(makeHoodMember(d, 'adult'));
}
if (chance(.45)) {
const kid = randomSimData(chance(.5) ? 'f' : 'm');
kid.name = kid.name.split(' ')[0] + ' ' + surname;
family.push(makeHoodMember(kid, 'child'));
}
lots.push({
id: 'L' + i,
name: surname,
roof: ROOF_COLORS[i % ROOF_COLORS.length],
gx: spots[i].gx, gy: spots[i].gy,
family,
friendship: randi(10, 35), // household-level vibe
});
}
return { lots };
}
function makeHoodMember(data, ageStage) {
return {
id: 'n' + Math.random().toString(36).slice(2, 9),
name: data.name,
gender: data.gender || choice(['f', 'm']),
skin: data.skin ?? randi(0, SKINS.length - 1),
hairStyle: data.hairStyle ?? 0,
hairColor: data.hairColor ?? randi(0, HAIRS.length - 1),
shirt: data.shirt ?? randi(0, SHIRTS.length - 1),
pants: data.pants ?? randi(0, PANTS.length - 1),
traits: data.traits || Object.fromEntries(TRAITS.map(t => [t, randi(1, 9)])),
aspiration: data.aspiration || choice(['fortune', 'knowledge', 'family', 'romance', 'popularity']),
ageStage,
rel: {}, // playerSimId -> {ltr,str} persistent memory
movedIn: false,
lastVisitDay: -99,
lastCallDay: -99,
};
}
/* ---------------- persistent visitor memory ---------------- */
function simFromHoodMeta(meta) {
const v = new Sim({
name: meta.name, gender: meta.gender, skin: meta.skin,
hairStyle: meta.hairStyle, hairColor: meta.hairColor,
shirt: meta.shirt, pants: meta.pants,
traits: { ...meta.traits }, aspiration: meta.aspiration,
ageStage: meta.ageStage,
isVisitor: true,
x: G.world.mailbox.x, y: LOT_H - 2,
});
v.hoodMeta = meta;
// seed remembered relationships with the household
for (const s of G.sims) {
if (s.isVisitor) continue;
const mem = meta.rel[s.id];
const r = v.getRel(s);
if (mem) { r.ltr = mem.ltr; r.str = mem.str; }
else { r.ltr = clamp(G.neighborhoodFriendBase + randi(-15, 25), 0, 60); }
}
return v;
}
function syncVisitorMemory(v) {
if (!v.hoodMeta) return;
for (const s of G.sims) {
if (s === v || s.isVisitor) continue;
const r = v.rels && v.rels.get(s.id);
if (r) v.hoodMeta.rel[s.id] = { ltr: Math.round(r.ltr), str: Math.round(r.str) };
}
v.hoodMeta.lastVisitDay = G.time.day;
}
function spawnVisitor(forceMeta = null) {
if (G.sims.filter(s => s.isVisitor).length >= 2 && !forceMeta) { toast('🏠 You already have company!'); return null; }
let meta = forceMeta;
if (!meta && chance(.75)) {
const pool = [];
for (const lot of G.neighborhood.lots)
for (const m of lot.family)
if (!m.movedIn && m.lastVisitDay < G.time.day && !G.sims.some(s => s.hoodMeta === m))
pool.push(m);
if (pool.length) meta = choice(pool);
}
let v;
if (meta) {
v = simFromHoodMeta(meta);
toast(`👋 ${v.name.split(' ')[0]} from the ${meta.name.split(' ').slice(1).join(' ') || meta.name} household dropped by!`, 'good');
} else {
const data = randomSimData();
data.name = data.name.split(' ')[0] + ' ' + choice(LAST_NAMES);
v = new Sim({ ...data, isVisitor: true, x: G.world.mailbox.x, y: LOT_H - 2 });
toast(`👋 ${v.name} dropped by to visit!`, 'good');
}
v.leaveAtMin = G.time.absMin + 240 + randi(0, 120);
v.needs.social = 40;
G.addSim(v);
const spot = G.world.findFreeSpotNear(G.world.mailbox.x, LOT_H - 4, 8);
if (spot) { const p = G.world.findPath(v.x, v.y, spot[0], spot[1]); if (p) v.setPath(p); }
return v;
}
/* ---------------- daily stroll schedule ---------------- */
function scheduleVisitors() {
const dayStart = Math.floor(G.time.absMin / 1440) * 1440;
G.visitsToday = [];
const n = randi(0, 2);
for (let i = 0; i < n; i++) G.visitsToday.push(dayStart + randi(600, 1290));
G.visitsToday.sort((a, b) => a - b);
}
function processVisits() {
while (G.visitsToday && G.visitsToday.length && G.time.absMin >= G.visitsToday[0]) {
G.visitsToday.shift();
if (G.mode === 'live' || G.mode === 'hood') spawnVisitor();
}
}
/* ============================================================
* Neighborhood view (mode 'hood')
* ============================================================ */
function enterHood() {
setMode('hood');
AudioSys.sfx('click');
}
function exitHood() { setMode('live'); }
function hoodLotRects(W, H) {
// layout in relative coords → CSS px
const cx = W / 2, cy = H / 2;
const rects = [{ key: 'player', x: cx - 90, y: cy - 70, w: 180, h: 150 }];
for (const lot of G.neighborhood.lots)
rects.push({ key: lot.id, lot, x: cx + lot.gx * W * .30 - 80, y: cy + lot.gy * H * .42 - 65, w: 160, h: 140 });
return rects;
}
function drawMiniHouse(ctx, x, y, w, h, roof, isPlayer, selected) {
ctx.save();
if (selected) { ctx.strokeStyle = '#ffd23e'; ctx.lineWidth = 3; ctx.strokeRect(x - 4, y - 4, w + 8, h + 8); }
// lawn pad
ctx.fillStyle = '#79b356';
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h - 18, w * .52, h * .22, 0, 0, 7); ctx.fill();
// house body
ctx.fillStyle = '#efe6d2';
ctx.fillRect(x + w * .18, y + h * .38, w * .64, h * .42);
// roof
ctx.fillStyle = roof;
ctx.beginPath();
ctx.moveTo(x + w * .08, y + h * .40);
ctx.lineTo(x + w * .5, y + h * .06);
ctx.lineTo(x + w * .92, y + h * .40);
ctx.closePath(); ctx.fill();
// door + windows
ctx.fillStyle = '#7c5836';
ctx.fillRect(x + w * .44, y + h * .58, w * .12, h * .22);
ctx.fillStyle = '#bfe3ef';
ctx.fillRect(x + w * .26, y + h * .50, w * .12, h * .13);
ctx.fillRect(x + w * .62, y + h * .50, w * .12, h * .13);
// tree
ctx.fillStyle = '#8a6438'; ctx.fillRect(x + w * .86, y + h * .58, 5, 14);
ctx.fillStyle = '#4a8a4a';
ctx.beginPath(); ctx.arc(x + w * .885, y + h * .52, 11, 0, 7); ctx.fill();
if (isPlayer) {
ctx.font = `${Math.round(h * .14)}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('⭐', x + w / 2, y + h * .02);
}
ctx.restore();
}
function heartsFor(lot) {
// best relationship any resident has with any member of this lot
let best = lot.friendship * .5;
for (const s of G.sims) {
if (s.isVisitor || !s.rels) continue;
for (const m of lot.family) {
const r = s.rels.get(m.id);
if (r) best = Math.max(best, r.ltr);
}
}
return Math.max(0, Math.min(5, Math.round(best / 20)));
}
function drawHood() {
const c = R.ctx, W = R.W, H = R.H;
c.save();
/* sky & grass */
const sky = c.createLinearGradient(0, 0, 0, H * .45);
sky.addColorStop(0, '#8ecfe8'); sky.addColorStop(1, '#cfeaf2');
c.fillStyle = sky; c.fillRect(0, 0, W, H * .45);
const grass = c.createLinearGradient(0, H * .4, 0, H);
grass.addColorStop(0, '#8cc06a'); grass.addColorStop(1, '#5f9a44');
c.fillStyle = grass; c.fillRect(0, H * .42, W, H * .58);
/* clouds */
c.fillStyle = 'rgba(255,255,255,.85)';
for (let i = 0; i < 4; i++) {
const cxp = ((R.time * 8 + i * 340) % (W + 200)) - 100, cyp = 40 + (i % 2) * 46;
c.beginPath(); c.arc(cxp, cyp, 22, 0, 7); c.arc(cxp + 24, cyp - 8, 17, 0, 7); c.arc(cxp - 22, cyp - 4, 15, 0, 7); c.fill();
}
/* winding road */
c.strokeStyle = '#cfc4ae'; c.lineWidth = 34; c.lineCap = 'round';
c.beginPath(); c.moveTo(-40, H * .78); c.bezierCurveTo(W * .3, H * .6, W * .7, H * .95, W + 40, H * .68); c.stroke();
c.strokeStyle = '#efe6cf'; c.lineWidth = 3; c.setLineDash([16, 14]);
c.beginPath(); c.moveTo(-40, H * .78); c.bezierCurveTo(W * .3, H * .6, W * .7, H * .95, W + 40, H * .68); c.stroke();
c.setLineDash([]);
/* title */
c.fillStyle = '#2c3a2a'; c.font = 'bold 30px Georgia,serif'; c.textAlign = 'left';
c.fillText('🏘️ Pleasantview', 28, 48);
c.font = '14px sans-serif'; c.fillStyle = '#41503c';
c.fillText('Click a house to meet the neighbors — press N or Esc to go home', 28, 72);
/* lots */
const sel = G.hoodSel;
drawMiniHouse(c, W / 2 - 90, H / 2 - 70, 180, 150, '#b5432e', true, false);
c.fillStyle = '#233021'; c.font = 'bold 15px sans-serif'; c.textAlign = 'center';
const playerFam = G.sims.find(s => !s.isVisitor);
c.fillText(playerFam ? playerFam.name.split(' ').slice(1).join(' ') + ' Household' : 'Your Household', W / 2, H / 2 + 96);
G.hoodRects = [];
for (const lot of G.neighborhood.lots) {
const rx = W / 2 + lot.gx * W * .30 - 80, ry = H / 2 + lot.gy * H * .42 - 65;
const hovered = G.hoodHover === lot.id;
if (hovered) { c.fillStyle = 'rgba(255,210,62,.18)'; c.fillRect(rx - 8, ry - 8, 176, 156); }
drawMiniHouse(c, rx, ry, 160, 130, lot.roof, false, sel === lot.id);
c.fillStyle = '#233021'; c.font = 'bold 15px sans-serif'; c.textAlign = 'center';
c.fillText(lot.name + ' Household', rx + 80, ry + 148);
/* friendship hearts */
const hearts = heartsFor(lot);
c.font = '13px sans-serif';
let hx = rx + 80 - hearts * 8;
for (let hh = 0; hh < hearts; hh++) { c.fillText('❤️', hx + hh * 17, ry + 168); }
G.hoodRects.push({ lot, x: rx - 8, y: ry - 8, w: 176, h: 172 });
}
/* info card */
if (sel) drawHoodCard(c, W, H);
/* close button */
c.fillStyle = 'rgba(30,26,24,.85)';
roundRect(c, W - 118, 20, 92, 38, 10); c.fill();
c.fillStyle = '#fff'; c.font = 'bold 15px sans-serif'; c.textAlign = 'center';
c.fillText('🏠 Home', W - 72, 44);
G.hoodHomeBtn = { x: W - 118, y: 20, w: 92, h: 38 };
c.restore();
}
function drawHoodCard(c, W, H) {
const lot = G.neighborhood.lots.find(l => l.id === G.hoodSel);
if (!lot) return;
const cw = 320, ch = 120 + lot.family.length * 74 + 66;
const x = W - cw - 26, y = H / 2 - ch / 2;
c.save();
c.fillStyle = 'rgba(250,246,236,.97)';
roundRect(c, x, y, cw, ch, 14); c.fill();
c.strokeStyle = lot.roof; c.lineWidth = 3; roundRect(c, x, y, cw, ch, 14); c.stroke();
c.fillStyle = '#233021'; c.font = 'bold 19px Georgia,serif'; c.textAlign = 'left';
c.fillText(`🏡 The ${lot.name}s`, x + 18, y + 32);
c.font = '12px sans-serif'; c.fillStyle = '#6a6a5f';
G.hoodBtns = [];
let yy = y + 62;
const refSim = G.selectedSim && !G.selectedSim.isVisitor ? G.selectedSim : G.sims.find(s => !s.isVisitor);
for (const m of lot.family) {
if (m.movedIn) continue;
/* avatar */
c.fillStyle = SKINS[m.skin % SKINS.length];
c.beginPath(); c.arc(x + 36, yy + 22, 17, 0, 7); c.fill();
c.fillStyle = HAIRS[m.hairColor % HAIRS.length];
c.beginPath(); c.arc(x + 36, yy + 16, 16, Math.PI, 0); c.fill();
/* name + stage */
c.fillStyle = '#233021'; c.font = 'bold 14px sans-serif';
c.fillText(m.name, x + 62, yy + 14);
c.font = '12px sans-serif'; c.fillStyle = '#6a6a5f';
const onLot = G.sims.some(s => s.hoodMeta === m);
c.fillText(`${m.ageStage === 'child' ? '🧒 Child' : '🧑 Adult'}${onLot ? ' · visiting now 👋' : ''}`, x + 62, yy + 31);
/* rel bar toward refSim */
const mem = refSim && refSim.rels ? null : null; // (kept for clarity)
const memRel = refSim && refSim.rels.get(m.id);
const val = memRel ? (memRel.ltr + 100) / 2 : 30 + lot.friendship * .4;
c.fillStyle = '#ddd6c6'; roundRect(c, x + 62, yy + 40, 170, 9, 4); c.fill();
c.fillStyle = val > 60 ? '#59b356' : val > 40 ? '#d8a53a' : '#c0574a';
roundRect(c, x + 62, yy + 40, Math.max(8, 170 * val / 100), 9, 4); c.fill();
c.fillStyle = '#6a6a5f'; c.font = '11px sans-serif'; c.textAlign = 'right';
c.fillText(memRel ? (memRel.ltr > 60 ? 'friends ❤️' : memRel.ltr > 20 ? 'friendly' : memRel.ltr < -20 ? 'tense ⚔️' : 'acquainted') : 'not met yet', x + 300, yy + 49);
c.textAlign = 'left';
/* buttons */
const by = yy + 52;
const canInvite = !onLot;
const canCall = !onLot && m.lastCallDay < G.time.day;
drawHoodBtn(c, x + 62, by, 108, 26, '👋 Invite Over', canInvite ? lot.roof : '#b9b2a2', !canInvite);
G.hoodBtns.push({ x: x + 62, y: by, w: 108, h: 26, fn: () => inviteHoodMember(m), disabled: !canInvite });
drawHoodBtn(c, x + 182, by, 118, 26, '📞 Phone Chat', canCall ? '#4a8a4a' : '#b9b2a2', !canCall);
G.hoodBtns.push({ x: x + 182, y: by, w: 118, h: 26, fn: () => callHoodMember(m), disabled: !canCall });
yy += 74;
}
/* close card */
drawHoodBtn(c, x + 18, y + ch - 44, cw - 36, 30, '✖ Close', '#5c5648', false);
G.hoodBtns.push({ x: x + 18, y: y + ch - 44, w: cw - 36, h: 30, fn: () => { G.hoodSel = null; }, disabled: false });
c.restore();
}
function drawHoodBtn(c, x, y, w, h, label, color, disabled) {
c.fillStyle = color;
roundRect(c, x, y, w, h, 8); c.fill();
c.globalAlpha = disabled ? .55 : 1;
c.fillStyle = '#fff'; c.font = 'bold 12px sans-serif'; c.textAlign = 'center';
c.fillText(label, x + w / 2, y + h / 2 + 4);
c.globalAlpha = 1;
c.textAlign = 'left';
}
function inviteHoodMember(m) {
AudioSys.sfx('click');
const already = G.sims.some(s => s.hoodMeta === m);
if (already) { toast('They are already at your place!', ''); return; }
if (G.sims.filter(s => s.isVisitor).length >= 2) { toast('🏠 Not enough room — you have company already.', 'bad'); return; }
const v = spawnVisitor(m);
if (v) { exitHood(); toast(`📞 ${v.name} said they'd love to come over!`, 'good'); }
}
function callHoodMember(m) {
AudioSys.sfx('chime');
m.lastCallDay = G.time.day;
const refSim = G.selectedSim && !G.selectedSim.isVisitor ? G.selectedSim : G.sims.find(s => !s.isVisitor);
if (refSim) {
const r = refSim.rels.get(m.id);
if (r) r.ltr = clamp(r.ltr + 3, -100, 100);
}
toast(`📞 You had a nice chat with ${m.name}.`, 'good');
}
function hoodClick(px, py) {
if (G.hoodHomeBtn && px >= G.hoodHomeBtn.x && px <= G.hoodHomeBtn.x + G.hoodHomeBtn.w &&
py >= G.hoodHomeBtn.y && py <= G.hoodHomeBtn.y + G.hoodHomeBtn.h) { exitHood(); return; }
if (G.hoodSel) {
for (const b of (G.hoodBtns || [])) {
if (!b.disabled && px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) { b.fn(); return; }
}
}
for (const r of (G.hoodRects || [])) {
if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) {
G.hoodSel = (G.hoodSel === r.lot?.id) ? null : r.lot.id;
AudioSys.sfx(r.lot ? 'chime' : 'error');
return;
}
}
G.hoodSel = null;
}
function hoodHover(px, py) {
G.hoodHover = null;
for (const r of (G.hoodRects || []))
if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) G.hoodHover = r.lot.id;
}
+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();
}
+997
View File
@@ -0,0 +1,997 @@
/* ============================================================
* render.js — isometric renderer: terrain, walls, furniture
* painters (procedural pixel art), sims, lighting, ghosts
* ============================================================ */
'use strict';
const R = {
canvas: null, ctx: null,
W: 0, H: 0,
time: 0, // real seconds accumulated for animations
};
function initRender(canvas) {
R.canvas = canvas;
R.ctx = canvas.getContext('2d');
resizeRender();
window.addEventListener('resize', resizeRender);
}
function resizeRender() {
if (!R.canvas) return;
R.W = R.canvas.width = window.innerWidth;
R.H = R.canvas.height = window.innerHeight;
}
/* ---------------- geometry helpers ---------------- */
function tileCornerPx(x, y) {
const [sx, sy] = isoToScreen(x, y);
return [sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y];
}
function diamondPath(ctx, x, y, z = 0) {
const p = [
tileCornerPx(x, y), // N corner (top)
tileCornerPx(x + 1, y), // E corner (right)
tileCornerPx(x + 1, y + 1), // S (bottom)
tileCornerPx(x, y + 1), // W (left)
];
ctx.beginPath();
ctx.moveTo(p[0][0], p[0][1] - z);
ctx.lineTo(p[1][0], p[1][1] - z);
ctx.lineTo(p[2][0], p[2][1] - z);
ctx.lineTo(p[3][0], p[3][1] - z);
ctx.closePath();
}
/** axis-aligned iso box anchored at cell (x,y) covering fw×fh tiles, height hp px */
function isoBoxPath(ctx, x, y, fw, fh, z0, z1) {
// corners in tile units
const pts = [[x, y], [x + fw, y], [x + fw, y + fh], [x, y + fh]];
const scr = pts.map(([px, py]) => {
const [sx, sy] = isoToScreen(px, py);
return [sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y];
});
// top face
ctx.beginPath();
ctx.moveTo(scr[0][0], scr[0][1] - z1);
ctx.lineTo(scr[1][0], scr[1][1] - z1);
ctx.lineTo(scr[2][0], scr[2][1] - z1);
ctx.lineTo(scr[3][0], scr[3][1] - z1);
ctx.closePath();
}
function shade(hex, f) {
const n = parseInt(hex.slice(1), 16);
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
}
/** draw a 3D box (top/left/right faces) in screen px around center point */
function box3d(ctx, cx, cy, wPx, dPx, hPx, colTop, colL, colR) {
// wPx along +x screen dir (right-down), dPx along +y (left-down)
const hx = wPx / 2, hy = dPx / 2;
const ux = (TW / 2) / TW, uy = TH / TW; // normalized iso dirs scaled later
const X = (dx, dy) => [cx + (dx - dy) * 0.5, cy + (dx + dy) * 0.25];
const A = X(-hx, -hy), B = X(hx, -hy), C = X(hx, hy), D = X(-hx, hy);
// top
ctx.fillStyle = colTop;
ctx.beginPath();
ctx.moveTo(A[0], A[1] - hPx); ctx.lineTo(B[0], B[1] - hPx);
ctx.lineTo(C[0], C[1] - hPx); ctx.lineTo(D[0], D[1] - hPx);
ctx.closePath(); ctx.fill();
// right face (B-C)
ctx.fillStyle = colR;
ctx.beginPath();
ctx.moveTo(B[0], B[1] - hPx); ctx.lineTo(C[0], C[1] - hPx);
ctx.lineTo(C[0], C[1]); ctx.lineTo(B[0], B[1]);
ctx.closePath(); ctx.fill();
// left face (D-C)
ctx.fillStyle = colL;
ctx.beginPath();
ctx.moveTo(D[0], D[1] - hPx); ctx.lineTo(C[0], C[1] - hPx);
ctx.lineTo(C[0], C[1]); ctx.lineTo(D[0], D[1]);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(20,16,28,.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
/* ============================================================
* MAIN DRAW
* ============================================================ */
function draw() {
const ctx = R.ctx;
R.time += G.dtReal;
ctx.clearRect(0, 0, R.W, R.H);
/* sky backdrop */
const skyGrad = ctx.createLinearGradient(0, 0, 0, R.H);
const nightness = getNightness();
skyGrad.addColorStop(0, mixColor('#7ec8f0', '#0a1030', nightness));
skyGrad.addColorStop(1, mixColor('#bfe3c0', '#141c44', nightness));
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, R.W, R.H);
if (!G.world) return;
/* lot backdrop */
if (!G.world) return;
drawGround(ctx);
drawBuildGrid(ctx);
/* depth-sorted entities */
const items = [];
collectWalls(items);
collectObjects(items);
collectFires(items);
collectGhosts(items);
collectSims(items);
collectFx(items);
items.sort((a, b) => a.depth - b.depth || a.sub - b.sub);
for (const it of items) it.fn(ctx);
drawGhost(ctx);
drawLighting(ctx);
drawWeather(ctx);
}
/* ---------------- weather overlay ---------------- */
let raindrops = null;
function drawWeather(ctx) {
if (!G.weather) return;
if (G.weather.flash > 0) {
ctx.fillStyle = `rgba(255,255,240,${clamp(G.weather.flash, 0, .8) * .55})`;
ctx.fillRect(0, 0, R.W, R.H);
}
if (G.weather.type !== 'rain') return;
// persistent light dim while raining
ctx.fillStyle = 'rgba(25,35,60,.14)';
ctx.fillRect(0, 0, R.W, R.H);
// raindrops
const want = Math.floor(R.W / 6);
if (!raindrops || raindrops.length !== want) {
raindrops = Array.from({ length: want }, () => ({
x: Math.random() * R.W, y: Math.random() * R.H,
v: 700 + Math.random() * 500, l: 10 + Math.random() * 12,
}));
}
const dt = G.dtReal;
ctx.strokeStyle = 'rgba(180,205,235,.5)';
ctx.lineWidth = 1;
ctx.beginPath();
for (const d of raindrops) {
d.y += d.v * dt; d.x -= d.v * dt * .18;
if (d.y > R.H) { d.y = -20; d.x = Math.random() * (R.W + 200); }
if (d.x < -30) d.x += R.W + 60;
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - d.l * .18, d.y - d.l);
}
ctx.stroke();
}
/* ---------------- ground & floors ---------------- */
function drawGround(ctx) {
const W = G.world;
for (let y = 0; y < W.h; y++) {
for (let x = 0; x < W.w; x++) {
const fid = W.floor[y * W.w + x] ?? 0;
const f = FLOORS[fid] || FLOORS[0];
diamondPath(ctx, x, y);
const check = (x + y) % 2 === 0;
ctx.fillStyle = check ? f.c1 : f.c2;
ctx.fill();
// subtle inner edge
ctx.strokeStyle = 'rgba(0,0,0,.06)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// mailbox
const mb = W.mailbox;
const [mx, my] = tileCornerPx(mb.x + .5, mb.y + .9);
ctx.font = `${18 * G.cam.zoom}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('📮', mx, my - 14 * G.cam.zoom);
if (G.mailBillsDue && !G.billsPaid) {
ctx.font = `${13 * G.cam.zoom}px sans-serif`;
ctx.fillText('✉️', mx + 12 * G.cam.zoom, my - 22 * G.cam.zoom);
}
// dirt puddles & scorch marks
for (const p of G.world.dirtPuddle || []) {
diamondPath(ctx, p.x, p.y);
if (p.kind === 'scorch') {
const a = Math.min(.85, p.t / 800);
ctx.fillStyle = `rgba(28,24,22,${a})`;
} else if (p.kind === 'puke') {
ctx.fillStyle = `rgba(140,170,60,${Math.min(.8, p.t / 400)})`;
} else {
ctx.fillStyle = `rgba(110,80,40,${Math.min(.75, p.t / 300)})`;
}
ctx.fill();
}
}
function drawBuildGrid(ctx) {
if (G.mode !== 'build' && G.mode !== 'buy') return;
ctx.strokeStyle = 'rgba(255,255,255,.13)';
ctx.lineWidth = 1;
for (let y = 0; y <= G.world.h; y++) {
const a = tileCornerPx(0, y), b = tileCornerPx(G.world.w, y);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
for (let x = 0; x <= G.world.w; x++) {
const a = tileCornerPx(x, 0), b = tileCornerPx(x, G.world.h);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
}
/* ---------------- walls ---------------- */
function collectWalls(items) {
const W = G.world;
for (const [k, wall] of W.walls) {
const [x, y, e] = k.split(',');
const xi = +x, yi = +y;
const depth = xi + yi + (e === 'n' ? 0.02 : 0.03);
items.push({ depth, sub: 0, fn: (ctx) => drawWall(ctx, xi, yi, e, wall) });
}
}
function drawWall(ctx, x, y, e, wall) {
const z = WALL_H * G.cam.zoom;
const A = e === 'n' ? tileCornerPx(x, y) : tileCornerPx(x, y);
const B = e === 'n' ? tileCornerPx(x + 1, y) : tileCornerPx(x, y + 1);
const base = shade(wall.color || '#efe6d4', e === 'n' ? 1.0 : 0.82);
const lit = shade(wall.color || '#efe6d4', e === 'n' ? 1.12 : 0.92);
if (wall.kind === 'door' || wall.kind === 'window') {
// two posts + opening
const t1 = 0.14, t2 = 0.86;
seg(A, B, 0, t1); seg(A, B, t2, 1);
if (wall.kind === 'door') {
seg(A, B, t1, t2, true); // lintel across top
// door slab ajar
const dx = lerp(A[0], B[0], .5), dy = lerp(A[1], B[1], .5);
ctx.strokeStyle = '#6e4a26';
ctx.lineWidth = Math.max(2, 3 * G.cam.zoom);
ctx.beginPath();
ctx.moveTo(dx, dy - z);
ctx.lineTo(dx + 6 * G.cam.zoom, dy - z + 10 * G.cam.zoom);
ctx.stroke();
} else {
// window: bottom sill band + glass + top band
bandSeg(A, B, 0.05, z * .32);
bandSeg(A, B, 0.78, z * .22);
// glass
ctx.fillStyle = 'rgba(160,210,240,.45)';
const g0 = ptAt(A, B, t1), g1 = ptAt(A, B, t2);
ctx.beginPath();
ctx.moveTo(g0[0], g0[1] - z * .36); ctx.lineTo(g1[0], g1[1] - z * .36);
ctx.lineTo(g1[0], g1[1] - z * .74); ctx.lineTo(g0[0], g0[1] - z * .74);
ctx.closePath(); ctx.fill();
}
} else {
// solid wall
ctx.fillStyle = base;
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]);
ctx.lineTo(B[0], B[1] - z); ctx.lineTo(A[0], A[1] - z);
ctx.closePath(); ctx.fill();
// lit inner face hint
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]);
ctx.lineTo(B[0], B[1] - z * .25); ctx.lineTo(A[0], A[1] - z * .25);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(30,22,15,.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(A[0], A[1] - z); ctx.lineTo(B[0], B[1] - z); ctx.lineTo(B[0], B[1]);
ctx.stroke();
}
function seg(P, Q, t0, t1, topOnly = false) {
const p0 = ptAt(P, Q, t0), p1 = ptAt(P, Q, t1);
ctx.fillStyle = base;
ctx.beginPath();
if (!topOnly) {
ctx.moveTo(p0[0], p0[1]); ctx.lineTo(p1[0], p1[1]);
ctx.lineTo(p1[0], p1[1] - z); ctx.lineTo(p0[0], p0[1] - z);
} else {
ctx.moveTo(p0[0], p0[1] - z); ctx.lineTo(p1[0], p1[1] - z);
ctx.lineTo(p1[0], p1[1] - z * .8); ctx.lineTo(p0[0], p0[1] - z * .8);
}
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(30,22,15,.4)';
ctx.stroke();
}
function bandSeg(P, Q, hFrac, hh) {
const yy = P[1] - hh;
const y1 = Q[1] - hh;
ctx.fillStyle = lit;
ctx.fillRect(Math.min(P[0], Q[0]), Math.min(yy, y1) , Math.abs(Q[0] - P[0]) + 2, Math.abs(hh) );
}
function ptAt(P, Q, t) { return [lerp(P[0], Q[0], t), lerp(P[1], Q[1], t)]; }
}
/* ============================================================
* OBJECT PAINTERS — procedural pixel art, centered at anchor
* ============================================================ */
const PAINTERS = {
chair(ctx, o, t) { box3d(ctx, 0, 0, 30, 30, 16, '#a8743f', '#8a5c30', '#7a5028');
ctx.fillStyle = '#7a4f26'; ctx.fillRect(-14, -34, 28, 20); },
stool(ctx, o, t) { box3d(ctx, 0, 0, 24, 24, 12, '#b5824a', '#96683a', '#865a30'); },
sofa(ctx, o, t) {
const len = o.h * TH * .95;
box3d(ctx, 0, -len * .18, 52, len, 14, '#4f7fd0', '#3d63a8', '#35569a');
ctx.fillStyle = '#3d63a8'; ctx.fillRect(-24, -len * .95 - 16, 48, 22);
ctx.fillStyle = '#6fa0e8'; ctx.fillRect(-20, -len * .72, 40, 8);
ctx.fillStyle = '#6fa0e8'; ctx.fillRect(-20, -len * .38, 40, 8);
},
loveseat(ctx, o, t) {
const len = o.h * TH * .95;
box3d(ctx, 0, -len * .2, 46, len, 13, '#c05a8a', '#9c4770', '#8a3e62');
ctx.fillStyle = '#9c4770'; ctx.fillRect(-21, -len * .95 - 14, 42, 20);
},
table(ctx, o, t) { box3d(ctx, 0, -4, 54, 54, 20, '#c99b62', '#a87c48', '#98703f'); },
coffeeTable(ctx, o, t) { box3d(ctx, 0, -2, o.w * TW * .8, 34, 12, '#b5824a', '#96683a', '#7a5028'); },
desk(ctx, o, t) { box3d(ctx, 0, -4, o.w * TW * .85, 36, 22, '#a87c48', '#8a6438', '#7a562e'); },
bedSingle(ctx, o, t) { drawBed(ctx, o, '#d9d9e2', '#7f9fd9'); },
bedDouble(ctx, o, t) { drawBed(ctx, o, '#e8e2ef', '#c98aa8'); },
toilet(ctx, o, t) {
box3d(ctx, 0, 4, 30, 34, 14, '#f2f2f6', '#d8d8de', '#c8c8d0');
ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.ellipse(0, -2, 13, 9, 0, 0, 7); ctx.fill();
ctx.strokeStyle = '#b8b8c0'; ctx.stroke();
ctx.fillStyle = '#e8e8ee'; ctx.fillRect(-14, -30, 28, 18);
if (o.dirty > .3) { ctx.fillStyle = `rgba(140,110,60,${o.dirty * .5})`; ctx.fillRect(-12, -28, 24, 14); }
},
shower(ctx, o, t) {
box3d(ctx, 0, 6, 44, 44, 6, '#bcd8e8', '#9dbdd2', '#8fb0c6');
ctx.strokeStyle = '#aac8da'; ctx.lineWidth = 3;
ctx.strokeRect(-20, -52, 40, 52);
ctx.fillStyle = 'rgba(190,225,245,.35)'; ctx.fillRect(-20, -52, 40, 52);
ctx.fillStyle = '#8899aa'; ctx.fillRect(-6, -58, 12, 6);
if (o.usedBy) { ctx.fillStyle = 'rgba(200,235,255,.8)';
for (let i = 0; i < 5; i++) ctx.fillRect(-14 + i * 7, -46 + ((t * 60 + i * 13) % 40), 2, 6); }
},
bathtub(ctx, o, t) {
box3d(ctx, 0, -o.h * 6, 44, o.h * TH * .8, 18, '#eef4f8', '#ccd8e2', '#bcc8d2');
ctx.fillStyle = '#cfe6f2'; ctx.beginPath(); ctx.ellipse(0, -8, 15, 10, 0, 0, 7); ctx.fill();
},
sink(ctx, o, t) {
box3d(ctx, 0, 2, 36, 26, 16, '#f2f2f6', '#d8d8de', '#c8c8d0');
ctx.fillStyle = '#8899aa'; ctx.fillRect(-2, -26, 4, 10);
},
mirror(ctx, o, t) {
ctx.fillStyle = '#8a6438'; ctx.fillRect(-16, -58, 32, 40);
ctx.fillStyle = '#bfe3f2'; ctx.fillRect(-13, -55, 26, 34);
ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.fillRect(-13, -55, 8, 34);
},
fridge(ctx, o, t) {
box3d(ctx, 0, 0, 40, 36, 52, '#e8ecf2', '#ccd2dc', '#bcc2cc');
ctx.fillStyle = '#9aa4b0'; ctx.fillRect(12, -40, 3, 16); ctx.fillRect(12, -18, 3, 10);
ctx.fillStyle = '#ffd23e'; ctx.fillRect(-14, -34, 8, 6);
},
stove(ctx, o, t) {
box3d(ctx, 0, 0, 40, 36, 26, '#d8dce4', '#b8bec8', '#a8aeb8');
ctx.fillStyle = '#333844';
ctx.beginPath(); ctx.arc(-8, -22, 5, 0, 7); ctx.arc(8, -22, 5, 0, 7); ctx.fill();
if (o.usedBy) { ctx.fillStyle = `rgba(255,${100 + Math.sin(t * 9) * 60},40,.9)`;
ctx.beginPath(); ctx.arc(-8, -26, 4 + Math.sin(t * 13) * 2, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(8, -26, 4 + Math.cos(t * 11) * 2, 0, 7); ctx.fill(); }
},
counter(ctx, o, t) {
box3d(ctx, 0, 0, 52, 40, 22, '#c9a26a', '#a87c48', '#98703f');
ctx.fillStyle = '#e8e2d4'; ctx.fillRect(-24, -24, 48, 6);
},
trash(ctx, o, t) {
box3d(ctx, 0, 2, 26, 26, 18, '#7a8494', '#646e7e', '#586270');
if (o.dirty > .5) { ctx.font = '12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🪰', 8, -22 + Math.sin(t * 5) * 3); }
},
tv(ctx, o, t) {
box3d(ctx, 0, 4, o.w * TW * .8, 22, 10, '#4a4038', '#3a322c', '#332c27');
ctx.fillStyle = '#22201e'; ctx.fillRect(-o.w * TW * .35, -52, o.w * TW * .7, 40);
const on = !!o.usedBy;
if (on) {
const flick = [' #4a90e2', '#3ac05a', '#e2c04a'][Math.floor(t * 3) % 3];
ctx.fillStyle = flick.trim();
ctx.fillRect(-o.w * TW * .32, -49, o.w * TW * .64, 34);
ctx.fillStyle = 'rgba(255,255,255,.25)';
for (let i = 0; i < 4; i++)
ctx.fillRect(-o.w * TW * .3 + Math.random() * o.w * TW * .5, -47 + Math.random() * 28, 6, 3);
} else { ctx.fillStyle = '#101418'; ctx.fillRect(-o.w * TW * .32, -49, o.w * TW * .64, 34); }
},
stereo(ctx, o, t) {
box3d(ctx, 0, 0, 34, 26, 30, '#2e3038', '#23252c', '#1e2026');
ctx.fillStyle = o.usedBy ? '#43e05a' : '#445058'; ctx.beginPath(); ctx.arc(-7, -15, 6, 0, 7); ctx.fill();
ctx.fillStyle = o.usedBy ? '#43e05a' : '#445058'; ctx.beginPath(); ctx.arc(7, -15, 6, 0, 7); ctx.fill();
if (o.usedBy) { ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🎵', -14, -34 + Math.abs(Math.sin(t * 4)) * -8);
ctx.fillText('🎶', 14, -34 + Math.abs(Math.cos(t * 4)) * -8); }
},
computer(ctx, o, t) {
box3d(ctx, 0, 6, 40, 30, 16, '#d8d4c8', '#b8b4a8', '#a8a498');
ctx.fillStyle = '#2a2e38'; ctx.fillRect(-12, -44, 24, 20);
ctx.fillStyle = o.usedBy ? '#4ab8e2' : '#14181e'; ctx.fillRect(-10, -42, 20, 16);
if (o.usedBy) { ctx.fillStyle = 'rgba(255,255,255,.5)';
for (let i = 0; i < 3; i++) ctx.fillRect(-9 + i * 7, -41 + ((t * 30 + i * 5) % 13), 4, 2); }
ctx.fillStyle = '#3a3e48'; ctx.fillRect(-14, -24, 28, 4);
},
phone(ctx, o, t) {
box3d(ctx, 0, 4, 22, 20, 12, '#d94a4a', '#b83a3a', '#a83232');
ctx.fillStyle = '#fff'; ctx.fillRect(-6, -18, 12, 8);
},
bookshelf(ctx, o, t) {
box3d(ctx, 0, 0, 44, 26, 48, '#8a6438', '#704e28', '#62441f');
const cols = ['#c94a4a', '#4a72c9', '#43b05a', '#e8a33d', '#8a52c9'];
for (let row = 0; row < 3; row++)
for (let i = 0; i < 5; i++) {
ctx.fillStyle = cols[(i + row * 2) % cols.length];
ctx.fillRect(-17 + i * 7, -42 + row * 14, 5, 11);
}
},
easel(ctx, o, t) {
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(-14, 8); ctx.lineTo(0, -52); ctx.lineTo(14, 8); ctx.stroke();
ctx.fillStyle = '#f2ede2'; ctx.fillRect(-18, -48, 36, 28);
if (o.usedBy) {
ctx.fillStyle = ['#4a90e2','#e25a4a','#43b05a'][Math.floor(t) % 3];
ctx.beginPath(); ctx.arc(Math.sin(t * 3) * 10, -36 + Math.cos(t * 2) * 6, 4, 0, 7); ctx.fill();
}
},
treadmill(ctx, o, t) {
box3d(ctx, 0, 4, 34, 52, 8, '#3a4048', '#2e343a', '#282e34');
ctx.strokeStyle = '#5a6470'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(-12, 0); ctx.lineTo(-12, -40); ctx.lineTo(12, -40); ctx.stroke();
if (o.usedBy) { ctx.fillStyle = 'rgba(120,220,255,.6)';
ctx.fillRect(-14 + Math.sin(t * 8) * 3, -34, 4, 4); }
},
piano(ctx, o, t) {
box3d(ctx, 0, 0, o.w * TW * .85, 40, 26, '#2a2228', '#201a1f', '#18131a');
ctx.fillStyle = '#f2f2f2';
for (let i = 0; i < 10; i++) ctx.fillRect(-o.w * TW * .36 + i * 8, -14, 6, 12);
ctx.fillStyle = '#111';
for (let i = 0; i < 7; i++) ctx.fillRect(-o.w * TW * .34 + i * 11 + 4, -14, 4, 8);
if (o.usedBy) { ctx.font = '12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🎵', Math.sin(t * 3) * 16, -40 - Math.abs(Math.sin(t * 5)) * 8); }
},
chessboard(ctx, o, t) {
box3d(ctx, 0, -2, 46, 46, 18, '#a87c48', '#8a6438', '#7a562e');
ctx.fillStyle = '#e8dcc8'; ctx.fillRect(-16, -24, 32, 16);
ctx.fillStyle = '#5a4426';
for (let r = 0; r < 2; r++) for (let c = 0; c < 4; c++) ctx.fillRect(-16 + c * 8 + (r ? 4 : 0), -23 + r * 7, 4, 6);
ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('♟', -8, -28); ctx.fillText('♞', 8, -28);
},
crib(ctx, o, t) {
box3d(ctx, 0, 2, 40, 40, 16, '#e8dcc8', '#c9bda6', '#b8ac96');
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 3;
for (let i = 0; i < 5; i++) {
const xx = -16 + i * 8;
ctx.beginPath(); ctx.moveTo(xx, -34); ctx.lineTo(xx, -14); ctx.stroke();
}
ctx.fillStyle = '#c9a24a';
ctx.fillRect(-20, -38, 40, 5);
// sleeping baby bump when occupied
if (o.usedBy && o.usedBy.ageStage === 'baby') {
ctx.fillStyle = SKINS[o.usedBy.skin % SKINS.length];
ctx.beginPath(); ctx.arc(0, -22, 7, 0, 7); ctx.fill();
ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
if (chance(.02)) ctx.fillText('💤', 12, -30);
}
},
easel(ctx, o, t) {
// tripod legs
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(-14, 2); ctx.lineTo(0, -40);
ctx.moveTo(14, 2); ctx.lineTo(0, -40);
ctx.moveTo(0, 2); ctx.lineTo(0, -34);
ctx.stroke();
// canvas
ctx.fillStyle = '#f7f2e6'; ctx.fillRect(-13, -36, 26, 20);
ctx.strokeStyle = '#c9bda6'; ctx.lineWidth = 2; ctx.strokeRect(-13, -36, 26, 20);
// a dab of art
ctx.fillStyle = ['#e84a1a', '#3e6fa8', '#4a8a4a', '#ffd23e'][Math.floor(t / 2) % 4];
ctx.beginPath(); ctx.arc(-5 + (Math.sin(t) * 5), -27 + Math.cos(t * .7) * 4, 3.4, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(6, -30, 2.6, 0, 7); ctx.fill();
},
toybox(ctx, o, t) { box3d(ctx, 0, 2, 36, 28, 20, '#d94a4a', '#b83a3a', '#a83232');
ctx.fillStyle = '#ffd23e'; ctx.fillRect(-14, -24, 28, 5);
ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🪀', -9, -26); ctx.fillText('🧸', 9, -27);
},
gravestone(ctx, o, t) { box3d(ctx, 0, 2, 26, 12, 24, '#9aa2ab', '#7f878f', '#70787f');
ctx.fillStyle = '#6a727a'; ctx.fillRect(-6, -20, 12, 3);
ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('RIP', 0, -24);
if (chance(.02)) { ctx.font = '10px sans-serif'; ctx.fillText('🕯️', 10, -18); }
},
plant(ctx, o, t) {
box3d(ctx, 0, 4, 24, 24, 14, '#b5651e', '#964f18', '#864414');
ctx.fillStyle = '#3d8a3d';
ctx.beginPath(); ctx.ellipse(0, -22, 14, 16, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#4da34d';
ctx.beginPath(); ctx.ellipse(-5, -26, 8, 10, -.4, 0, 7); ctx.fill();
ctx.beginPath(); ctx.ellipse(6, -24, 7, 9, .4, 0, 7); ctx.fill();
},
lamp(ctx, o, t) {
ctx.fillStyle = '#5a5048'; ctx.fillRect(-2, -34, 4, 34);
ctx.fillStyle = o.lightOn ? '#ffe9a8' : '#d8cfb8';
ctx.beginPath(); ctx.moveTo(-12, -34); ctx.lineTo(12, -34); ctx.lineTo(8, -48); ctx.lineTo(-8, -48); ctx.closePath(); ctx.fill();
},
painting(ctx, o, t) {
ctx.fillStyle = '#8a6438'; ctx.fillRect(-16, -64, 32, 26);
ctx.fillStyle = ['#7fb2e2','#e2c07f','#9be27f'][o.id % 3];
ctx.fillRect(-13, -61, 26, 20);
ctx.fillStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.arc(-4, -53, 5, 0, 7); ctx.fill();
},
fountain(ctx, o, t) {
box3d(ctx, 0, 0, o.w * TW * .8, o.h * TH * 1.4, 14, '#c8ccd4', '#a8acb6', '#989ca6');
ctx.fillStyle = '#6fc0e8'; ctx.beginPath(); ctx.ellipse(0, -10, o.w * TW * .3, o.h * TH * .5, 0, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.6)';
const jh = 14 + Math.sin(t * 4) * 5;
ctx.fillRect(-2, -14 - jh, 4, jh);
ctx.font = `${12 * G.cam.zoom}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('💧', 6, -20 - jh);
},
};
function drawBed(ctx, o, sheetCol, blankCol) {
const len = o.h * TH * 1.05;
box3d(ctx, 0, -len * .12, o.w * TW * .78, len, 12, '#8a6438', '#704e28', '#62441f');
// mattress + pillow + blanket
ctx.fillStyle = sheetCol;
ctx.fillRect(-o.w * TW * .34, -len * .95, o.w * TW * .68, len * .8);
ctx.fillStyle = '#ffffff';
ctx.fillRect(-o.w * TW * .3, -len * .93, o.w * TW * .6, len * .18);
ctx.fillStyle = blankCol;
ctx.fillRect(-o.w * TW * .34, -len * .62, o.w * TW * .68, len * .45);
ctx.strokeStyle = 'rgba(30,20,10,.3)';
ctx.strokeRect(-o.w * TW * .34, -len * .95, o.w * TW * .68, len * .8);
}
function collectObjects(items) {
for (const o of G.world.objects) {
const def = OBJECTS[o.defId];
// draw at the deepest footprint cell so multi-tile objects sort correctly
const depth = (o.x + o.w - 1) + (o.y + o.h - 1) - 0.25;
items.push({
depth,
sub: 1,
fn: (ctx) => drawObject(ctx, o, def),
});
}
// dirty dish piles
for (const p of (G.dishPiles || [])) {
if (p.n <= 0) continue;
items.push({ depth: p.x + p.y + 0.3, sub: 3, fn: (ctx) => {
const [ax, ay] = isoToScreen(p.x, p.y);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
ctx.save();
ctx.translate(px, py);
ctx.scale(G.cam.zoom, G.cam.zoom);
const stacks = Math.min(4, Math.ceil(p.n));
for (let i = 0; i < stacks; i++) {
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🍽️', (i % 2 ? 8 : -7), -4 - Math.floor(i / 2) * 9);
}
if (p.n >= 3) { ctx.font = '10px sans-serif'; ctx.fillText('🪰', 12, -18 + Math.sin(R.time * 5) * 3); }
ctx.restore();
}});
}
}
function drawObject(ctx, o, def) {
const vcx = o.x + o.w / 2, vcy = o.y + o.h / 2;
const [px, py] = tileCornerPx(vcx - .5 + .5 * 0, vcy - .5);
// center of footprint on screen:
const [ax, ay] = isoToScreen(vcx, vcy);
const cx = ax * G.cam.zoom + G.cam.x;
const cy = ay * G.cam.zoom + G.cam.y;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(G.cam.zoom, G.cam.zoom);
// soft ground shadow
ctx.fillStyle = 'rgba(20,16,28,.18)';
ctx.beginPath();
ctx.ellipse(0, o.h * TH * .18, o.w * TW * .34, o.h * TH * .3, 0, 0, 7);
ctx.fill();
const painter = PAINTERS[def.shape];
if (painter) painter(ctx, o, R.time);
else { ctx.fillStyle = '#caa'; ctx.fillRect(-14, -30, 28, 30); }
if (o.broken) {
// sparks & smoke over broken objects
ctx.font = '13px sans-serif'; ctx.textAlign = 'center';
const jx = Math.sin(R.time * 23 + o.id) * 4, jy = -Math.abs(Math.cos(R.time * 17)) * 6;
ctx.fillText('⚡', jx, -46 - def.h * 8 + jy);
ctx.fillStyle = 'rgba(60,60,66,.5)';
ctx.beginPath();
ctx.arc(0, -40 - def.h * 8, 7 + Math.sin(R.time * 3) * 2, 0, 7);
ctx.fill();
}
ctx.restore();
// usage sparkle: show who uses it
if (o.usedBy && def.cat === 'electronics') { /* anim handled in painters */ }
}
/* ============================================================
* SIM SPRITES
* ============================================================ */
/**
* Draw a sim character at screen point (px,py)=feet position.
* opts: {zoom, facing, anim, animT, skin, hairStyle, hairColorIdx, shirt, pants, scale}
*/
function drawSimSprite(ctx, px, py, s, opts = {}) {
const zoom = opts.zoom ?? G.cam.zoom;
const stageK = s.ageStage === 'baby' ? .55 : s.ageStage === 'child' ? .78 : 1;
const sc = (opts.scale ?? 1) * zoom * stageK;
const t = opts.animT ?? 0;
const anim = s.ageStage === 'baby' ? 'idle' : (opts.anim ?? 'idle');
const facing = opts.facing ?? 0;
const skin = SKINS[s.skin % SKINS.length];
const hair = HAIRS[s.hairColor % HAIRS.length];
const shirt = SHIRTS[s.shirt % SHIRTS.length];
const pants = PANTS[s.pants % PANTS.length];
ctx.save();
ctx.translate(px, py);
ctx.scale(sc, sc);
if (facing === 1) ctx.scale(-1, 1); // W mirrors E
const back = facing === 2; // facing away (N)
const walking = anim === 'walk';
const swing = walking ? Math.sin(t * 11) : 0;
const bob = walking ? Math.abs(Math.sin(t * 11)) * 2 : (anim === 'dance' ? Math.abs(Math.sin(t * 6)) * 4 : Math.sin(t * 2.2) * .8);
const danceWave = anim === 'dance' ? Math.sin(t * 6) * 8 : 0;
const exercise = anim === 'exercise';
if (anim === 'lie') {
// lying down: horizontal body
ctx.fillStyle = 'rgba(20,16,28,.2)';
ctx.beginPath(); ctx.ellipse(0, 2, 26, 8, 0, 0, 7); ctx.fill();
ctx.fillStyle = pants; ctx.fillRect(-22, -10, 18, 9); // legs
ctx.fillStyle = shirt; ctx.fillRect(-4, -11, 22, 11); // torso
ctx.fillStyle = skin; ctx.beginPath(); ctx.arc(24, -8, 8, 0, 7); ctx.fill(); // head
ctx.fillStyle = hair; ctx.beginPath(); ctx.arc(24, -12, 8, Math.PI, 0); ctx.fill();
ctx.restore();
return;
}
const sitPose = anim === 'sit';
const legH = sitPose ? 8 : 14;
const bodyY = -(legH + 16) - bob;
// shadow
ctx.fillStyle = 'rgba(20,16,28,.25)';
ctx.beginPath(); ctx.ellipse(0, 1, 11, 4.5, 0, 0, 7); ctx.fill();
// legs
ctx.fillStyle = pants;
if (sitPose) {
ctx.fillRect(-8, -8, 6, 9); ctx.fillRect(2, -8, 6, 9);
ctx.fillRect(-8, -2, 16, 4); // shins forward
} else if (walking) {
ctx.fillRect(-7 + swing * 3, -14, 5, 14);
ctx.fillRect(2 - swing * 3, -14, 5, 14);
} else {
ctx.fillRect(-7, -14, 5, 14); ctx.fillRect(2, -14, 5, 14);
}
// torso
ctx.fillStyle = shirt;
ctx.fillRect(-8, bodyY, 16, sitPose ? 12 : 16);
// arms
ctx.fillStyle = shirt;
if (anim === 'dance') {
ctx.fillRect(-13, bodyY - 6 - danceWave * .5, 5, 14);
ctx.fillRect(8, bodyY - 6 + danceWave * .5, 5, 14);
} else if (exercise) {
ctx.fillRect(-12, bodyY + Math.sin(t * 9) * 4, 5, 13);
ctx.fillRect(7, bodyY - Math.sin(t * 9) * 4, 5, 13);
} else {
ctx.fillRect(-12, bodyY + 2, 5, sitPose ? 8 : 13);
ctx.fillRect(7, bodyY + 2, 5, sitPose ? 8 : 13);
}
// hands
ctx.fillStyle = skin;
ctx.fillRect(-12, bodyY + (sitPose ? 9 : 14), 5, 4);
ctx.fillRect(7, bodyY + (sitPose ? 9 : 14), 5, 4);
// head
const headY = bodyY - 9;
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(0, headY, 8.4, 0, 7); ctx.fill();
// hair styles: 0 short, 1 long, 2 ponytail, 3 spiky/bald-cap
ctx.fillStyle = hair;
const hs = s.hairStyle % 4;
ctx.beginPath();
if (back) ctx.arc(0, headY, 8.4, Math.PI * .95, Math.PI * 2.05);
else ctx.arc(0, headY - 1.5, 8.4, Math.PI, Math.PI * 2);
ctx.fill();
if (hs === 1) { ctx.fillRect(-9, headY - 2, 5, 14); ctx.fillRect(4, headY - 2, 5, 14); }
if (hs === 2) { ctx.beginPath(); ctx.arc(back ? 0 : 9, headY + (back ? -2 : 2), 4.4, 0, 7); ctx.fill(); }
if (hs === 3) { for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.moveTo(i * 5 - 2, headY - 7); ctx.lineTo(i * 5, headY - 13); ctx.lineTo(i * 5 + 2, headY - 7); ctx.fill(); } }
// face
if (!back) {
ctx.fillStyle = '#222';
const ex = facing === 1 ? -1 : 0;
ctx.fillRect(-4 + ex, headY - 1, 2, 2.6);
ctx.fillRect(2 + ex, headY - 1, 2, 2.6);
ctx.fillStyle = 'rgba(220,120,120,.5)';
ctx.fillRect(-6 + ex, headY + 2, 3, 2); ctx.fillRect(3 + ex, headY + 2, 3, 2);
}
// carried plate
if (s.carryPlate) {
ctx.fillStyle = '#f2f2f2'; ctx.beginPath(); ctx.ellipse(12, bodyY + 12, 6, 3, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#c9803d'; ctx.beginPath(); ctx.ellipse(12, bodyY + 11, 3.4, 2, 0, 0, 7); ctx.fill();
}
ctx.restore();
// sickly pallor
if (s.sickUntil && G.time.absMin < s.sickUntil) {
ctx.fillStyle = 'rgba(130,200,90,.30)';
ctx.beginPath(); ctx.arc(0, bodyY - 8, 10, 0, 7); ctx.fill();
}
// plumbob for selected
if (s.selected) {
const bobP = Math.sin(R.time * 3) * 3;
const [gx, gy] = [px, py - (opts.heightOffset ?? 62) * zoom * stageK + bobP * zoom];
ctx.fillStyle = s.plumbob();
ctx.beginPath();
ctx.moveTo(gx, gy - 7 * zoom); ctx.lineTo(gx + 5 * zoom, gy);
ctx.lineTo(gx, gy + 7 * zoom); ctx.lineTo(gx - 5 * zoom, gy);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.stroke();
}
// thought bubble
if (s.bubble) {
const bx = px + 16 * zoom * Math.max(stageK, .8), by = py - 74 * zoom * Math.max(stageK, .8);
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.beginPath(); ctx.arc(bx, by, 12 * zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(bx - 10 * zoom, by + 10 * zoom, 3 * zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(bx - 14 * zoom, by + 15 * zoom, 1.6 * zoom, 0, 7); ctx.fill();
ctx.font = `${13 * zoom}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(s.bubble.icon, bx, by + 1);
ctx.textBaseline = 'alphabetic';
}
}
function collectSims(items) {
for (const s of G.sims) {
if (!s.atHome && !s.atWork) continue;
if (!s.atHome) continue;
const depth = s.x + s.y + 0.35;
items.push({ depth, sub: 2, fn: (ctx) => {
const [ax, ay] = isoToScreen(s.x, s.y);
const px = ax * G.cam.zoom + G.cam.x;
const py = ay * G.cam.zoom + G.cam.y;
// selection ring
if (s.selected) {
ctx.strokeStyle = 'rgba(255,210,62,.9)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(px, py, 14 * G.cam.zoom, 7 * G.cam.zoom, 0, 0, 7); ctx.stroke();
}
drawSimSprite(ctx, px, py, s, { animT: s.animT * .12 + R.time, anim: s.anim, facing: s.facing });
}});
}
}
function collectFires(items) {
for (const f of (G.fires || [])) {
items.push({ depth: f.x + f.y + .5, sub: 4, fn: (ctx) => {
const [ax, ay] = isoToScreen(f.x + .5, f.y + .5);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
const flick = Math.sin(R.time * 13 + f.x * 7) * .2 + 1;
ctx.save();
ctx.translate(px, py);
ctx.scale(G.cam.zoom, G.cam.zoom);
// glow
const g = ctx.createRadialGradient(0, -10, 2, 0, -10, 34 * flick);
g.addColorStop(0, 'rgba(255,160,40,.55)');
g.addColorStop(1, 'rgba(255,120,20,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, -10, 34 * flick, 0, 7); ctx.fill();
// flame tongues
for (let i = 0; i < 3; i++) {
const fx = (i - 1) * 7;
const fh = (16 + Math.sin(R.time * 11 + i * 2) * 6) * flick;
ctx.fillStyle = ['#e84a1a', '#ff8c1a', '#ffd23e'][i];
ctx.beginPath();
ctx.moveTo(fx - 6, 2);
ctx.quadraticCurveTo(fx - 8, -fh * .5, fx, -fh);
ctx.quadraticCurveTo(fx + 8, -fh * .5, fx + 6, 2);
ctx.closePath(); ctx.fill();
}
ctx.font = '14px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🔥', 0, -26 - Math.abs(Math.sin(R.time * 6)) * 6);
ctx.restore();
}});
}
}
function collectGhosts(items) {
const h = G.time.hourFloat;
if (!(h >= 1 && h < 4) || !G.ghosts) return;
for (const gh of G.ghosts) {
items.push({ depth: gh.x + gh.y + .4, sub: 3, fn: (ctx) => {
const [ax, ay] = isoToScreen(gh.x, gh.y);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
const bob = Math.sin(R.time * 2 + gh.wobble) * 5;
ctx.save();
ctx.globalAlpha = .45 + Math.sin(R.time * 3 + gh.wobble) * .12;
// translucent shroud
ctx.fillStyle = '#cfe8ff';
ctx.beginPath();
ctx.moveTo(px - 9 * G.cam.zoom, py);
ctx.quadraticCurveTo(px - 10 * G.cam.zoom, py - 30 * G.cam.zoom + bob, px, py - 32 * G.cam.zoom + bob);
ctx.quadraticCurveTo(px + 10 * G.cam.zoom, py - 30 * G.cam.zoom + bob, px + 9 * G.cam.zoom, py);
// wavy tail
ctx.quadraticCurveTo(px + 5 * G.cam.zoom, py - 6 * G.cam.zoom, px, py);
ctx.quadraticCurveTo(px - 5 * G.cam.zoom, py - 6 * G.cam.zoom, px - 9 * G.cam.zoom, py);
ctx.fill();
// face
ctx.globalAlpha = .9;
ctx.fillStyle = '#233021';
ctx.beginPath(); ctx.arc(px - 3 * G.cam.zoom, py - 22 * G.cam.zoom + bob, 1.4 * G.cam.zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(px + 3 * G.cam.zoom, py - 22 * G.cam.zoom + bob, 1.4 * G.cam.zoom, 0, 7); ctx.fill();
ctx.font = `${Math.round(11 * G.cam.zoom)}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('👻', px, py - 40 * G.cam.zoom + bob);
ctx.restore();
}});
}
}
/* fx layer (floating texts etc.) */
const FX = [];
function addFloatText(x, y, text, color = '#fff') {
FX.push({ x, y, text, color, t: 0 });
}
function collectFx(items) {
for (const f of FX) {
items.push({ depth: 9999, sub: 9, fn: (ctx) => {
const [ax, ay] = isoToScreen(f.x, f.y);
const px = ax * G.cam.zoom + G.cam.x;
const py = (ay * G.cam.zoom + G.cam.y) - 30 - f.t * 22;
ctx.globalAlpha = clamp(1 - f.t, 0, 1);
ctx.font = `bold ${13 * G.cam.zoom}px Segoe UI`;
ctx.textAlign = 'center';
ctx.fillStyle = f.color;
ctx.strokeStyle = 'rgba(0,0,0,.6)'; ctx.lineWidth = 3;
ctx.strokeText(f.text, px, py); ctx.fillText(f.text, px, py);
ctx.globalAlpha = 1;
}});
}
}
function tickFx(dt) {
for (const f of FX) f.t += dt;
for (let i = FX.length - 1; i >= 0; i--) if (FX[i].t >= 1.4) FX.splice(i, 1);
}
/* ---------------- ghost previews ---------------- */
function drawGhost(ctx) {
if (G.mode === 'buy' && G.buySel && G.mouseTile) {
const def = OBJECTS[G.buySel];
const rot = G.buyRot;
const w = rot ? def.h : def.w, h = rot ? def.w : def.h;
const tx = G.mouseTile[0], ty = G.mouseTile[1];
const ok = G.funds >= def.price && G.world.canPlace(def, tx, ty, rot);
// footprint cells
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
diamondPath(ctx, tx + dx, ty + dy);
ctx.fillStyle = ok ? 'rgba(80,220,100,.4)' : 'rgba(230,70,70,.4)';
ctx.fill();
ctx.strokeStyle = ok ? '#43e05a' : '#e05a5a';
ctx.stroke();
}
// translucent preview
ctx.globalAlpha = .65;
const fake = { id:-1, defId:G.buySel, x:tx, y:ty, rot, w, h, dirty:0, usedBy:null };
drawObject(ctx, fake, def);
ctx.globalAlpha = 1;
}
if (G.mode === 'build') {
const ht = G.hoverEdge;
if (ht && ['wall','door','window'].includes(G.buildTool)) {
const A = ht.e === 'n' ? tileCornerPx(ht.x, ht.y) : tileCornerPx(ht.x, ht.y);
const B = ht.e === 'n' ? tileCornerPx(ht.x + 1, ht.y) : tileCornerPx(ht.x, ht.y + 1);
const z = WALL_H * G.cam.zoom;
ctx.strokeStyle = G.buildTool === 'wall' ? 'rgba(255,255,255,.9)' : 'rgba(120,220,255,.95)';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(A[0], A[1] - z); ctx.lineTo(B[0], B[1] - z); ctx.stroke();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]); ctx.stroke();
ctx.setLineDash([]);
}
if (G.buildTool === 'floor' && G.mouseTile) {
diamondPath(ctx, G.mouseTile[0], G.mouseTile[1]);
const f = FLOORS[G.floorSel] || FLOORS[0];
ctx.fillStyle = f.c1; ctx.globalAlpha = .7; ctx.fill(); ctx.globalAlpha = 1;
ctx.strokeStyle = '#fff'; ctx.stroke();
}
if (G.buildTool === 'delWall' && G.hoverEdge) {
const A = tileCornerPx(G.hoverEdge.x, G.hoverEdge.y);
const B = G.hoverEdge.e === 'n' ? tileCornerPx(G.hoverEdge.x + 1, G.hoverEdge.y) : tileCornerPx(G.hoverEdge.x, G.hoverEdge.y + 1);
ctx.strokeStyle = 'rgba(255,80,80,.95)'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(A[0], A[1] - WALL_H * G.cam.zoom); ctx.lineTo(B[0], B[1] - WALL_H * G.cam.zoom); ctx.stroke();
}
}
}
/* ---------------- lighting ---------------- */
function getNightness() {
if (!G.time) return 0;
const h = G.time.hourFloat;
if (h >= 21 || h < 5) return 1;
if (h >= 19) return (h - 19) / 2;
if (h < 7) return (7 - h) / 2;
return 0;
}
function mixColor(c1, c2, t) {
const p = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
const a = p(c1), b = p(c2);
return `rgb(${Math.round(lerp(a[0], b[0], t))},${Math.round(lerp(a[1], b[1], t))},${Math.round(lerp(a[2], b[2], t))})`;
}
let lightCv = null;
function drawLighting(ctx) {
const n = getNightness();
if (n <= 0.02 && !(G.time.hourFloat >= 6 && G.time.hourFloat < 8) &&
!(G.time.hourFloat >= 17 && G.time.hourFloat < 19)) return;
if (!lightCv) { lightCv = document.createElement('canvas'); }
if (lightCv.width !== R.W || lightCv.height !== R.H) { lightCv.width = R.W; lightCv.height = R.H; }
const lc = lightCv.getContext('2d');
lc.clearRect(0, 0, R.W, R.H);
// darkness
lc.fillStyle = `rgba(10,14,44,${n * .52})`;
lc.fillRect(0, 0, R.W, R.H);
// dawn/dusk warmth
const h = G.time.hourFloat;
if ((h >= 6 && h < 8) || (h >= 17 && h < 19)) {
const wt = h < 8 ? (8 - h) / 2 : (h - 17) / 2;
lc.fillStyle = `rgba(255,150,60,${wt * .16})`;
lc.fillRect(0, 0, R.W, R.H);
}
// punch lights out
lc.globalCompositeOperation = 'destination-out';
const punch = (wx, wy, r, strength = 1) => {
const [ax, ay] = isoToScreen(wx, wy);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y - 20 * G.cam.zoom;
const rr = r * G.cam.zoom;
const g = lc.createRadialGradient(px, py, rr * .15, px, py, rr);
g.addColorStop(0, `rgba(0,0,0,${strength})`);
g.addColorStop(1, 'rgba(0,0,0,0)');
lc.fillStyle = g;
lc.beginPath(); lc.arc(px, py, rr, 0, 7); lc.fill();
};
for (const o of G.world.objects) {
const def = OBJECTS[o.defId];
if (def.light) punch(o.x + o.w / 2, o.y + o.h / 2, def.light, .9);
if (o.defId === 'tv' && o.usedBy) punch(o.x + 1, o.y + .5, 60, .5);
if (o.defId === 'stove' && o.usedBy) punch(o.x + .5, o.y + .5, 40, .6);
}
for (const f of (G.fires || [])) punch(f.x + .5, f.y + .5, 95, .85);
for (const s of G.sims) if (s.atHome) punch(s.x, s.y, 34, .35);
lc.globalCompositeOperation = 'source-over';
ctx.drawImage(lightCv, 0, 0);
}
/* ============================================================
* Portrait / CAS rendering (standalone canvases)
* ============================================================ */
function drawSimToCanvas(cv, simData, opts = {}) {
const c = cv.getContext('2d');
c.clearRect(0, 0, cv.width, cv.height);
const sc = opts.scale ?? Math.min(cv.width / 90, cv.height / 130);
const px = cv.width / 2 - (opts.offsetX ?? 0) * sc;
const py = cv.height - (opts.groundPad ?? 8);
const fakeSim = typeof simData === 'object' ? simData : { skin:0 };
const savedZoom = G.cam ? G.cam.zoom : 1;
if (opts.standalone !== false) {
// temporarily neutralize camera for plumbob math
}
drawSimSprite(c, px, py, fakeSim, {
zoom: sc, facing: opts.facing ?? 0, anim: opts.anim ?? 'idle',
animT: opts.animT ?? 0, scale: 1, heightOffset: opts.heightOffset ?? 62,
});
return c;
}
+339
View File
@@ -0,0 +1,339 @@
/* ============================================================
* sims.js — Sim: needs, mood, skills, personality, relationships,
* aging, movement along paths
* ============================================================ */
'use strict';
const WALK_TILES_PER_MIN = 1.35; // game minutes per tile
class Sim {
constructor(data = {}) {
this.id = data.id || uid();
this.name = data.name || 'Sim ' + this.id;
this.gender = data.gender || (chance(.5) ? 'm' : 'f');
this.skin = data.skin != null ? data.skin : randi(0, SKINS.length - 1);
this.hairStyle = data.hairStyle != null ? data.hairStyle : randi(0, 3);
this.hairColor = data.hairColor != null ? data.hairColor : randi(0, HAIRS.length - 1);
this.shirt = data.shirt != null ? data.shirt : randi(0, SHIRTS.length - 1);
this.pants = data.pants != null ? data.pants : randi(0, PANTS.length - 1);
this.traits = data.traits || { neat: randi(0,10), outgoing: randi(0,10), active: randi(0,10), playful: randi(0,10), nice: randi(0,10) };
this.aspiration = data.aspiration || choice(Object.keys(ASPIRATIONS));
// world state
this.x = data.x != null ? data.x : LOT_W / 2;
this.y = data.y != null ? data.y : LOT_H / 2;
this.path = []; // remaining tiles
this.facing = 0;
this.anim = 'idle'; // idle | walk | sit | lie | dance | exercise
this.animT = 0;
this.atHome = true; this.atWork = false; this.isVisitor = !!data.isVisitor;
this.leaveAtMin = data.leaveAtMin || 0; // visitor departure (absolute minute)
this.bubble = null; // {icon, t} thought bubble
// vitals
const n0 = { hunger:72, energy:82, bladder:74, hygiene:78, fun:62, social:66, comfort:70, room:55 };
this.needs = data.needs ? { ...n0, ...data.needs } : { ...n0 };
this.skills = data.skills || {};
for (const s of SKILLS) if (!(s.id in this.skills)) this.skills[s.id] = 0;
this.job = data.job ? { ...data.job } : null;
this.workdaysMissed = 0;
this.ageStage = data.ageStage || 'adult';
this.daysAlive = data.daysAlive || 0;
this.stageSince = data.stageSince != null ? data.stageSince : G.time ? G.time.absMin : 0;
this.pregnantUntil = data.pregnantUntil || 0;
this.schoolPerf = data.schoolPerf != null ? data.schoolPerf : 60;
this.cryT = 0;
this.lastCancelMsg = '';
this.memories = data.memories || [];
this.sickUntil = data.sickUntil || 0;
this.novelChapters = data.novelChapters || 0;
this.paintings = data.paintings || [];
/** rels: simId -> {str, ltr, name} — str=short-term(-100..100) ltr=long-term(-100..100) */
this.rels = new Map();
if (data.rels) for (const [k, v] of Object.entries(data.rels)) this.rels.set(+k, { ...v });
this.action = null; // current Action (ai.js)
this.queue = []; // player-queued actions (max 4)
this.wants = []; // active whims (WantSys)
this.selected = false;
this.talkCooldown = 0; // minutes until autonomous social allowed again
this.carryPlate = false;
}
/* ---------------- relationships ---------------- */
getRel(other) {
let r = this.rels.get(other.id);
if (!r) {
r = { str: other.isVisitor || this.isVisitor ? 15 : (this.familyWith(other) ? 40 : 12),
ltr: other.isVisitor || this.isVisitor ? 5 : (this.familyWith(other) ? 25 : 0),
name: other.name };
this.rels.set(other.id, r);
}
return r;
}
familyWith(other) {
return !this.isVisitor && !other.isVisitor && !this.visitorHousehold !== !other.isVisitor
? false : (!this.isVisitor && !other.isVisitor);
}
friendsCount() {
let c = 0;
for (const [id, r] of this.rels) if (r.ltr >= 50) c++;
return c;
}
/* ---------------- mood ---------------- */
moodScore() {
const w = { hunger:.24, energy:.20, bladder:.13, hygiene:.08, fun:.13, social:.11, comfort:.06, room:.05 };
let m = 0;
for (const k in w) m += clamp(this.needs[k], 0, 100) * w[k];
return m;
}
plumbob() { const m = this.moodScore(); return m > 60 ? '#3ddc55' : m > 32 ? '#ffd23e' : '#ff4040'; }
/** milestone diary shown in the Bio tab */
addMemory(icon, text) {
this.memories ||= [];
this.memories.unshift({ icon, text, day: G.time.day });
if (this.memories.length > 24) this.memories.length = 24;
}
/* ---------------- needs ticking (per game-minute) ---------------- */
tickNeeds(min) {
const t = this.traits;
for (const key in NEEDS) {
if (key === 'room') { this.needs.room = lerp(this.needs.room, G.world.roomAt(this.x, this.y), .02 * min); continue; }
let d = NEEDS[key].decay * (min / 60); // decay rates are per-hour
// personality modifiers
if (key === 'energy' && t.active > 7) d *= 1.15;
if (key === 'hunger' && t.active > 7) d *= 1.2;
if (this.ageStage === 'baby') {
if (key === 'hunger') d *= 1.7;
if (key === 'social') d *= 1.5;
if (key === 'bladder') d *= 0.7;
if (key === 'fun') d = 0;
}
if (this.job) d *= 1; // same at work (handled while away abstractly)
// sleeping / sitting states slow decay & restore
if (this.anim === 'lie') {
if (key === 'energy') d = +0.85 * min * (this.sleepQuality || 1); // strong regen handled by action fx too
else d *= 0.15;
} else if (this.anim === 'sit') d *= 0.7;
if (key === 'fun' && G.weather && G.weather.type === 'rain') d *= 1.2; // rainy-day blues
if (this.sickUntil && (key === 'hunger' || key === 'energy')) d *= 1.35;
this.needs[key] = clamp(this.needs[key] + d, -5, 100);
}
/* --- critical failures --- */
if (this.needs.bladder <= 2 && !['pee'].includes(this.action?.def?.id)) {
this.needs.bladder = 40; this.needs.hygiene = Math.min(this.needs.hygiene, 8);
G.world.dirtPuddle ||= [];
G.world.dirtPuddle.push({ x: Math.round(this.x), y: Math.round(this.y), kind:'puddle', t: 300 });
toast(`💦 ${this.name} couldn't hold it!`, 'bad');
AudioSys.sfx('splash');
thought(this, '😭');
this.cancelAction('Accident');
}
if (this.needs.energy <= 1 && this.anim !== 'lie') {
this.needs.energy = 12;
toast(`😵 ${this.name} passed out from exhaustion!`, 'bad');
thought(this, '💤');
AI.passOut(this);
}
/* --- sickness (food poisoning / flu): green pallor, slower regen, vomit --- */
if (this.sickUntil && G.time.absMin < this.sickUntil) {
if (Math.random() < .0012 * min) {
G.world.dirtPuddle ||= [];
G.world.dirtPuddle.push({ x: Math.round(this.x), y: Math.round(this.y), kind:'puke', t: 400 });
this.say('🤮'); AudioSys.sfx('thud');
this.needs.hunger = clamp(this.needs.hunger - 8, 0, 100);
}
if (Math.random() < .0006 * min) thought(this, '🤢');
// contagion via proximity
for (const o of G.sims) {
if (o === this || o.sickUntil || !o.atHome || o.ageStage === 'baby') continue;
if (dist2(o.x, o.y, this.x, this.y) < 4 && chance(.00025 * min)) makeSick(o);
}
} else if (this.sickUntil) { this.sickUntil = 0; toast(`😊 ${this.name} feels better!`, 'good'); }
/* --- starving is fatal --- */
if (this.needs.hunger <= 0) {
this.starveT = (this.starveT || 0) + min;
if (this.starveT > 900 && this.ageStage !== 'baby') dieOf(this, 'starvation');
} else this.starveT = Math.max(0, (this.starveT || 0) - min * .5);
}
gainSkill(id, amount) {
if (!(id in this.skills)) return;
const cur = this.skills[id];
if (cur >= 10) return;
// higher levels need enthusiasm (mood) — like Sims interest decay
this.skills[id] = clamp(cur + amount, 0, 10);
const before = Math.floor(cur), after = Math.floor(this.skills[id]);
if (after > before) {
const sk = SKILLS.find(s => s.id === id);
toast(`${sk.icon} ${this.name} reached ${sk.name} level ${after}!`, 'good');
AudioSys.sfx('level');
WantSys.notify(this, 'skill', { skill: id, level: after });
if (this.aspiration === 'knowledge') G.aspirationPoints += 50;
}
}
/* ---------------- movement ---------------- */
setPath(tiles) {
if (!tiles) return false;
this.path = tiles.slice();
return true;
}
walkAlong(min) {
if (!this.path.length) return true;
let budget = WALK_TILES_PER_MIN * min * (G.speedMul || 1) ;
while (budget > 0 && this.path.length) {
const [tx, ty] = this.path[0];
const dx = tx - this.x, dy = ty - this.y;
const d = Math.hypot(dx, dy);
if (d < 0.02) { this.x = tx; this.y = ty; this.path.shift(); continue; }
const step = Math.min(budget, d);
this.x += dx / d * step; this.y += dy / d * step;
this.facing = dirFromDelta(dx, dy);
this.anim = 'walk';
budget -= step;
}
return this.path.length === 0;
}
say(icon, dur = 90) { this.bubble = { icon, t: dur }; }
/** stop whatever the sim is doing */
cancelAction(msg = '') {
const a = this.action;
if (!a) return;
this.lastCancelMsg = msg;
if (typeof a.finish === 'function') { a.cancelMsg = msg; a.finish(); }
else { a.done = true; if (a.a?.busyWith === this) a.a.busyWith = null; }
if (this.action === a) this.action = null;
this.path = [];
if (this.anim === 'walk') this.anim = 'idle';
}
/* ---------------- per-minute master tick ---------------- */
tick(min) {
this.animT += min;
if (this.bubble) { this.bubble.t -= min * 6; if (this.bubble.t <= 0) this.bubble = null; }
if (this.talkCooldown > 0) this.talkCooldown -= min;
// life-stage transitions
if (this.ageStage === 'baby' && G.time.absMin - this.stageSince >= 4 * 1440) {
this.ageStage = 'child'; this.stageSince = G.time.absMin;
toast(`🎂 ${this.name} grew into a child!`, 'good'); AudioSys.sfx('level');
Bus.emit('simsChanged');
} else if (this.ageStage === 'child' && G.time.absMin - this.stageSince >= 8 * 1440) {
this.ageStage = 'adult'; this.stageSince = G.time.absMin;
toast(`🎂 ${this.name} grew into an adult!`, 'good'); AudioSys.sfx('fanfare');
Bus.emit('simsChanged');
}
// pregnancy full-term
if (this.pregnantUntil && G.time.absMin >= this.pregnantUntil) {
this.pregnantUntil = 0;
giveBirth(this);
}
// expectant mothers think about it now and then
if (this.pregnantUntil && Math.random() < .0008 * min) this.say('🍼');
if (!this.atHome) {
// working or arriving/away — needs decay slower off-lot
for (const k of ['hunger','bladder']) this.needs[k] = clamp(this.needs[k] + NEEDS[k].decay * min * .45, 0, 100);
this.needs.energy = clamp(this.needs.energy + NEEDS.energy.decay * min * .3, 0, 100);
return;
}
// babies stay put & fuss
if (this.ageStage === 'baby') {
this.anim = 'idle';
this.tickNeeds(min);
this.babyCry(min);
return;
}
if (this.path.length) { this.walkAlong(min); }
else if (this.anim === 'walk') this.anim = this.action?.pose === 'sit' ? 'sit' : 'idle';
this.tickNeeds(min);
if (this.action) this.action.tick(min);
else if (!this.isVisitor || chance(.002 * min)) AI.autonomize(this, min);
// visitors go home eventually
if (this.isVisitor) {
if (this.leavePending && !this.path.length && !this.action) { G.removeSim(this); return; }
if (G.time.absMin >= this.leaveAtMin) AI.visitorLeave(this);
}
}
babyCry(min) {
const upset = this.needs.hunger < 38 || this.needs.energy < 30 || this.needs.bladder < 22 || this.needs.social < 25;
if (!upset) { this.cryT = Math.max(0, this.cryT - min); return; }
this.cryT += min;
if (this.cryT > 8) {
this.cryT = 0;
this.say('😢', 60);
AudioSys.sfx('splash'); // wah-ish
for (const a of G.sims) {
if (a === this || !a.atHome || a.ageStage === 'baby') continue;
if (dist2(a.x, a.y, this.x, this.y) < 64) {
if (a.anim === 'lie' && a.needs.energy > 20) a.cancelAction('Woken by crying baby');
a.needs.energy = clamp(a.needs.energy - 2.5, 0, 100);
a.say('😪');
}
}
}
}
/* ---------------- persistence ---------------- */
serialize() {
return {
id:this.id, name:this.name, gender:this.gender, skin:this.skin,
hairStyle:this.hairStyle, hairColor:this.hairColor, shirt:this.shirt, pants:this.pants,
traits:{ ...this.traits }, aspiration:this.aspiration,
x:this.x, y:this.y, facing:this.facing,
needs:{ ...this.needs }, skills:{ ...this.skills },
job: this.job ? { ...this.job } : null,
marriedTo: this.marriedTo || null,
pregnantUntil: this.pregnantUntil, stageSince: this.stageSince,
schoolPerf: this.schoolPerf,
ageStage:this.ageStage, daysAlive:this.daysAlive,
memories: this.memories || [],
sickUntil: this.sickUntil || 0,
novelChapters: this.novelChapters || 0,
paintings: this.paintings || [],
isVisitor:false, // visitors are not saved
rels: Array.from(this.rels.entries()).filter(([id]) => G.simById(id)),
};
}
}
/* ---------- helpers ---------- */
function thought(sim, icon) { sim.say(icon); }
/* make a Sim from a CAS template object */
function simFromTemplate(t) {
return new Sim({
name: t.name, gender: t.gender, skin: t.skin, hairStyle: t.hairStyle,
hairColor: t.hairColor, shirt: t.shirt, pants: t.pants,
traits: { ...t.traits }, aspiration: t.aspiration,
});
}
function randomSimData(gender = null) {
const g = gender || (chance(.5) ? 'm' : 'f');
return {
name: choice(g === 'f' ? FIRST_NAMES_F : FIRST_NAMES_M) + ' ' + choice(LAST_NAMES),
gender: g,
nameCustom: false,
skin: randi(0, SKINS.length - 1), hairStyle: randi(0, 3), hairColor: randi(0, HAIRS.length - 1),
shirt: randi(0, SHIRTS.length - 1), pants: randi(0, PANTS.length - 1),
traits: Object.fromEntries(TRAITS.map(t => [t, randi(0, 10)])),
aspiration: choice(Object.keys(ASPIRATIONS)),
};
}
+473
View File
@@ -0,0 +1,473 @@
/* ============================================================
* ui.js — HUD, portraits, sim panel, buy catalog, build bar,
* pie menus, toasts, career picker
* ============================================================ */
'use strict';
/* ---------------- toasts ---------------- */
function toast(msg, cls = '') {
AudioSys.sfx(cls === 'bad' ? 'error' : 'toast');
const box = document.getElementById('toasts');
const el = document.createElement('div');
el.className = 'toast ' + cls;
el.innerHTML = msg;
box.appendChild(el);
while (box.children.length > 4) box.removeChild(box.firstChild);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .5s'; }, 4200);
setTimeout(() => el.remove(), 4800);
}
function toastBill(amount) {
AudioSys.sfx('bill');
const box = document.getElementById('toasts');
const el = document.createElement('div');
el.className = 'toast bad';
el.innerHTML = `📬 Bills due: <b>${fmtMoney(amount)}</b> <span class="envelope" id="payBillsBtn">PAY</span>`;
box.appendChild(el);
document.getElementById('payBillsBtn').onclick = () => {
if (G.funds >= amount) { G.funds -= amount; G.billsPaid = true; G.mailBillsDue = false;
toast(`✅ Bills paid: ${fmtMoney(amount)}`); el.remove(); Bus.emit('fundsChanged'); }
else toast('❌ Not enough money for the bills!', 'bad');
};
}
/* ---------------- HUD ---------------- */
function updateHud() {
document.getElementById('fundsVal').textContent = Math.floor(G.funds).toLocaleString('en-US');
const t = G.time;
document.getElementById('clockTime').textContent =
`${Math.floor(t.hour)}:${String(Math.floor((t.hourFloat % 1) * 60)).padStart(2, '0')}` +
` ${t.hour >= 12 ? 'PM' : 'AM'}`;
document.getElementById('clockDay').textContent = `${DAY_NAMES[(t.day - 1) % 7]}, Day ${t.day}`;
}
/* ---------------- portraits ---------------- */
const portraitEls = new Map();
function rebuildPortraits() {
const row = document.getElementById('portraitRow');
row.innerHTML = '';
portraitEls.clear();
for (const s of G.sims.filter(s => !s.isVisitor)) {
const d = document.createElement('div');
d.className = 'portrait' + (s.selected ? ' selected' : '');
const cv = document.createElement('canvas');
cv.width = 70; cv.height = 62;
d.appendChild(cv);
const nm = document.createElement('div'); nm.className = 'pname'; nm.textContent = s.name.split(' ')[0];
d.appendChild(nm);
const pb = document.createElement('div'); pb.className = 'plumbob'; pb.textContent = '🔷';
d.appendChild(pb);
const mb = document.createElement('div'); mb.className = 'moodbar';
const mfill = document.createElement('div'); mb.appendChild(mfill);
d.appendChild(mb);
d.onclick = () => { selectSim(s); };
row.appendChild(d);
portraitEls.set(s.id, { root:d, cv, plumbob:pb, mfill });
}
refreshPortraits();
}
function refreshPortraits() {
for (const s of G.sims.filter(s => !s.isVisitor)) {
const pe = portraitEls.get(s.id);
if (!pe) continue;
pe.root.classList.toggle('selected', !!s.selected);
const nmEl = pe.root.querySelector('.pname');
if (nmEl) nmEl.textContent = s.name.split(' ')[0] + (s.sickUntil && G.time.absMin < s.sickUntil ? ' 🤢' : '');
pe.plumbob.textContent = s.moodScore() > 60 ? '🟢' : s.moodScore() > 32 ? '🟡' : '🔴';
const m = s.moodScore();
pe.mfill.style.width = m + '%';
pe.mfill.style.background = m > 60 ? '#3ddc55' : m > 32 ? '#ffd23e' : '#ff4040';
drawSimToCanvas(pe.cv, s, { facing:0, scale: Math.min(70/90, 62/130) + .18, groundPad: 4 });
}
}
/* ---------------- sim selection ---------------- */
function selectSim(s) {
for (const o of G.sims) o.selected = false;
if (s) s.selected = true;
G.selectedSim = s;
refreshPortraits();
updateSimPanel();
}
/* ---------------- sim side panel ---------------- */
function updateSimPanel() {
const panel = document.getElementById('simPanel');
const s = G.selectedSim;
if (!s || G.mode === 'cas') { panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
document.getElementById('simPanelName').textContent = s.name + (s.isVisitor ? ' (visiting)' : '');
const m = s.moodScore();
document.getElementById('simPanelMood').textContent =
'Mood: ' + (m > 75 ? 'Elated 😄' : m > 55 ? 'Happy 🙂' : m > 35 ? 'Uneasy 😕' : m > 18 ? 'Miserable 😣' : 'Desperate 😫');
drawSimToCanvas(document.getElementById('simPortrait'), s, { facing:0, scale:1, groundPad:6 });
const body = document.getElementById('simPanelBody');
const tab = panel.dataset.tab || 'needs';
let html = '';
if (tab === 'needs') {
for (const k in NEEDS) {
const meta = NEEDS[k];
const v = clamp(s.needs[k], 0, 100);
const col = v > 55 ? '#43c15a' : v > 28 ? '#e8a33d' : '#e05252';
html += `<div class="needRow"><div class="nlabel"><span>${meta.icon} ${meta.label}</span><span>${Math.round(v)}</span></div>
<div class="nbar"><div style="width:${v}%;background:${col}"></div></div></div>`;
}
} else if (tab === 'wants') {
const asp = ASPIRATIONS[s.aspiration];
const lvl = Math.floor(G.aspirationPoints / 500);
const prog = G.aspirationPoints % 500;
html += `<div class="relCard"><div class="rname"><span>${asp.icon} ${asp.name}</span><span>Lvl ${lvl}</span></div>
<div class="nbar"><div style="width:${prog / 5}%;background:#c95ad9"></div></div>
<div style="font-size:11px;color:#9fb4ea;margin-top:4px">${prog}/500 to next level</div></div>`;
if (!s.wants || !s.wants.length) html += '<i>No whims right now…</i>';
for (const w of (s.wants || [])) {
const t = w.tpl;
const pct = t.count ? Math.round(w.progress / t.count * 100) : (t.amount ? Math.min(100, Math.round(w.bank / t.amount * 100)) : 0);
html += `<div class="relCard"><div class="rname"><span>${t.icon} ${t.label}</span><span>+${t.reward}</span></div>
${t.count || t.amount ? `<div class="relBar"><div style="width:${pct}%;background:#43c15a"></div></div>` : ''}
</div>`;
}
html += `<div style="font-size:11px;color:#9fb4ea">Whims are guided by the ${asp.name} aspiration. Fulfil them for aspiration points!</div>`;
} else if (tab === 'skills') {
for (const sk of SKILLS) {
const lvl = Math.floor(s.skills[sk.id]);
let pips = '';
for (let i = 0; i < 10; i++) pips += i < lvl ? '●' : '·';
html += `<div class="skillRow"><span>${sk.icon} ${sk.name}</span><span class="pips" style="color:#ffd23e">${pips}</span><b>${lvl}</b></div>`;
}
} else if (tab === 'rels') {
const others = G.sims.filter(o => o !== s);
if (!others.length) html += '<i>No other sims around yet.<br>Try the phone → Invite Neighbor!</i>';
for (const o of others) {
const r = s.getRel(o);
const ltrCol = r.ltr >= 50 ? '#43c15a' : r.ltr <= -25 ? '#e05252' : '#7f9fd9';
const strCol = r.str >= 40 ? '#43c15a' : r.str <= -20 ? '#e05252' : '#c9a24a';
const badge = r.ltr >= 75 ? '💞' : r.ltr >= 50 ? '🤝' : r.ltr <= -40 ? '⚔️' : '';
html += `<div class="relCard"><div class="rname"><span>${o.name} ${badge}</span><span>${Math.round(r.ltr)}</span></div>
<div class="relBar"><div style="width:${(r.ltr + 100) / 2}%;background:${ltrCol}"></div></div>
<div class="relBar"><div style="width:${(r.str + 100) / 2}%;background:${strCol}"></div></div>
</div>`;
}
} else if (tab === 'career') {
if (s.atWork) html += `<div class="careerLine">🚗 Currently <b>at work</b>.</div>`;
if (!s.job) {
html += `<div class="careerLine">❌ Unemployed.<br>Use a <b>computer</b> → Find a Job.</div>`;
} else {
const c = CAREERS.find(c => c.id === s.job.track);
const rank = c.ranks[s.job.rank];
const perf = s.job.perf ?? 50;
html += `<div class="careerLine">${c.icon} <b>${rank.title}</b><br>
${c.trackName} · Level ${s.job.rank + 1}/10<br>
💰 ${fmtMoney(rank.salary)}/day · 🕘 ${rank.hours[0]}:00${rank.hours[1]}:00<br>
Off: ${rank.offDays.map(d => DAY_NAMES[d]).join(', ')}</div>
<div class="needRow"><div class="nlabel"><span>📈 Performance</span><span>${Math.round(perf)}</span></div>
<div class="nbar"><div style="width:${perf}%;background:${perf > 66 ? '#43c15a' : perf > 33 ? '#e8a33d' : '#e05252'}"></div></div></div>
<div class="careerLine"><b>Next level needs:</b><br>${
Object.entries(rank.req).map(([k, v]) => `${SKILLS.find(x => x.id === k)?.icon || ''} ${SKILLS.find(x => x.id === k)?.name}: ${v}`).join('<br>') || '— just keep performance up!'
}</div>`;
}
html += `<hr><div class="bioLine">⭐ Aspiration points: ${Math.round(G.aspirationPoints)}</div>`;
} else if (tab === 'bio') {
const zod = ['Capricorn','Aquarius','Pisces','Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius'][s.id % 12];
html += `<div class="bioLine">🧑 Name: <b>${s.name}</b></div>
<div class="bioLine">${s.gender === 'm' ? '👨 Male' : '👩 Female'} · ${s.ageStage === 'elder' ? '🧓 Elder' : s.ageStage === 'child' ? '🧒 Child' : s.ageStage === 'baby' ? '👶 Baby' : '🧍 Adult'}</div>
<div class="bioLine">♒ Zodiac sign: ${zod}</div>
<div class="bioLine">✨ Aspiration: <b>${ASPIRATIONS[s.aspiration].icon} ${ASPIRATIONS[s.aspiration].name}</b><br>
<small style="color:#9fb4ea">${ASPIRATIONS[s.aspiration].desc}</small></div>
<div class="bioLine" style="margin-top:8px"><b>Personality</b></div>` +
TRAITS.map(tr => {
const labels = { neat:'Neat', outgoing:'Outgoing', active:'Active', playful:'Playful', nice:'Nice' };
return `<div class="skillRow"><span>${labels[tr]}</span><span class="pips" style="color:#ffd23e">${
'●'.repeat(s.traits[tr]) + '·'.repeat(10 - s.traits[tr])}</span></div>`;
}).join('');
// life milestones diary
const mem = (s.memories || []).slice(0, 8);
html += `<div class="bioLine" style="margin-top:8px"><b>📜 Memories</b></div>` +
(mem.length
? `<div style="max-height:130px;overflow:auto">` + mem.map(m =>
`<div class="bioLine">Day ${m.day} ${m.icon} ${m.text}</div>`).join('') + `</div>`
: `<div class="bioLine" style="color:#9aa">No memories yet — go live a little!</div>`);
if (s.novelChapters > 0) html += `<div class="bioLine">✍️ Writing a novel — chapter ${s.novelChapters}/10</div>`;
if ((s.paintings || []).length) html += `<div class="bioLine">🖼️ ${s.paintings.length} painting(s) ready to sell</div>`;
}
body.innerHTML = html;
}
/* ---------------- career chance cards ---------------- */
Bus.on('chanceCard', () => {
const p = G.pendingChance; if (!p) return;
const sim = G.simById(p.simId);
if (!sim || !sim.job) { G.pendingChance = null; setSpeed(1); return; }
const card = p.card;
let el = document.getElementById('chanceCard');
if (!el) { el = document.createElement('div'); el.id = 'chanceCard'; document.body.appendChild(el); }
const cname = (CAREERS.find(c => c.id === sim.job.track) || {}).name || 'Work';
el.innerHTML = `<div class="cc-box">
<div class="cc-head">💼 Career Opportunity — ${cname}</div>
<div class="cc-q">${card.q}</div>
<div class="cc-btns">${card.a.map((a, i) =>
`<button data-i="${i}">${a.icon || ''} ${a.label}</button>`).join('')}</div>
<div class="cc-sub">Time is paused while ${sim.name.split(' ')[0]} decides…</div>
</div>`;
el.classList.remove('hidden');
el.querySelectorAll('button').forEach(b => b.onclick = () => {
const a = card.a[+b.dataset.i];
if (a.fx && a.fx.dice != null) {
const win = chance(a.fx.dice);
applyChanceFx(sim, win ? a.fx.win : a.fx.lose);
toast(win ? `🎯 Bold move! It paid off for ${sim.name}.` : `😬 That backfired on ${sim.name}`, win ? 'good' : 'bad');
} else {
applyChanceFx(sim, a.fx || {});
toast(`${a.icon || ''} ${sim.name.split(' ')[0]} chose: ${a.label}`, '');
}
AudioSys.sfx('click');
el.classList.add('hidden');
G.pendingChance = null;
setSpeed(G.prevSpeedBeforeCard || 1);
});
});
/* tab clicks */
document.querySelectorAll('#simPanelTabs button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('#simPanelTabs button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
document.getElementById('simPanel').dataset.tab = b.dataset.tab;
updateSimPanel();
};
});
document.getElementById('simPanelClose').onclick = () => document.getElementById('simPanel').classList.add('hidden');
/* ---------------- BUY drawer ---------------- */
let buyThumbCache = new Map();
function thumbFor(defId) {
if (buyThumbCache.has(defId)) return buyThumbCache.get(defId).cloneNode ?
(() => { const c = document.createElement('canvas'); c.width = 84; c.height = 64;
c.getContext('2d').drawImage(buyThumbCache.get(defId), 0, 0); return c; })() : null;
const src = document.createElement('canvas'); src.width = 168; src.height = 128;
const c = src.getContext('2d');
const def = OBJECTS[defId];
c.save(); c.translate(84, 96); c.scale(.95, .95);
const fake = { id: 3, defId, x:0, y:0, rot:0, w:def.w, h:def.h, dirty:.2, usedBy:false };
const painter = PAINTERS[def.shape];
// emulate drawObject's local space
if (painter) painter(c, fake, 1.0);
else { c.fillStyle = '#caa'; c.fillRect(-14, -30, 28, 30); }
c.restore();
buyThumbCache.set(defId, src);
const out = document.createElement('canvas'); out.width = 84; out.height = 64;
out.getContext('2d').drawImage(src, 0, 0, 168, 128, 0, 0, 84, 64);
return out;
}
function openBuyDrawer() {
const drawer = document.getElementById('buyDrawer');
drawer.classList.remove('hidden');
const tabs = document.getElementById('buyTabs');
tabs.innerHTML = '';
let curCat = drawer.dataset.cat || 'seating';
for (const cat of BUY_CATS) {
const b = document.createElement('button');
b.textContent = cat.icon + ' ' + cat.label;
b.className = cat.id === curCat ? 'active' : '';
b.onclick = () => { drawer.dataset.cat = cat.id; openBuyDrawer(); };
tabs.appendChild(b);
}
const grid = document.getElementById('buyGrid');
grid.innerHTML = '';
for (const id in OBJECTS) {
const def = OBJECTS[id];
if (def.cat !== curCat) continue;
const card = document.createElement('div');
card.className = 'buyItem' + (G.buySel === id ? ' sel' : '');
card.appendChild(thumbFor(id));
const bn = document.createElement('div'); bn.className = 'bn'; bn.textContent = def.name;
const bp = document.createElement('div'); bp.className = 'bp'; bp.textContent = fmtMoney(def.price);
card.appendChild(bn); card.appendChild(bp);
card.onclick = () => { G.buySel = id; G.buyRot = 0; openBuyDrawer(); setHint(`Placing ${def.name} (${fmtMoney(def.price)}) — click a tile · R rotate · Esc cancel`); };
grid.appendChild(card);
}
}
function closeBuyDrawer() {
document.getElementById('buyDrawer').classList.add('hidden');
}
function setHint(txt) { document.getElementById('buyHint').innerHTML = txt; }
/* ---------------- BUILD bar ---------------- */
function openBuildBar() {
document.getElementById('buildBar').classList.remove('hidden');
const sw = document.getElementById('floorSwatches');
if (!sw.children.length) {
FLOORS.forEach((f, i) => {
if (f.outdoor) return;
const s = document.createElement('div');
s.className = 'swatch' + (i === G.floorSel ? ' sel' : '');
s.style.background = f.c1;
s.title = f.id;
s.onclick = () => { G.floorSel = i; openBuildBar(); };
sw.appendChild(s);
});
} else {
[...sw.children].forEach((el, i) => el.classList.toggle('sel', i === G.floorSel));
}
// wall color swatches
const ws = document.getElementById('wallSwatches');
if (!ws.children.length) {
const lbl = document.createElement('span');
lbl.style.cssText = 'font-size:11px;color:#9fb4ea;margin:0 2px;';
lbl.textContent = '🧱';
ws.appendChild(lbl);
WALL_COLORS.forEach((c, i) => {
const s = document.createElement('div');
s.className = 'swatch' + (c === G.wallColor ? ' sel' : '');
s.style.background = c;
s.onclick = () => { G.wallColor = c; openBuildBar(); };
ws.appendChild(s);
});
} else {
let ci = 0;
[...ws.children].forEach(el => {
if (!el.style.background) return; // label
el.classList.toggle('sel', WALL_COLORS[ci] === G.wallColor);
ci++;
});
}
document.querySelectorAll('#buildBar [data-tool]').forEach(b =>
b.classList.toggle('active', b.dataset.tool === G.buildTool));
document.getElementById('buildHint').innerHTML =
`Wall §70/segment · Door §250 · Window §180 · Floor §12/tile · Removing refunds 50% — <b>${{wall:'Drag across edges to build',door:'Click a wall segment',window:'Click a wall segment',floor:'Drag to paint floor',delWall:'Click walls/doors/windows to remove'}[G.buildTool]||''}</b>`;
}
function closeBuildBar() { document.getElementById('buildBar').classList.add('hidden'); }
/* build tool buttons */
document.querySelectorAll('#buildBar [data-tool]').forEach(b => {
b.onclick = () => { G.buildTool = b.dataset.tool; openBuildBar(); };
});
/* ---------------- PIE MENU ---------------- */
function showPie(px, py, entries, title = '') {
const pie = document.getElementById('pieMenu');
pie.innerHTML = '';
if (title) {
const t = document.createElement('div');
t.style.cssText = 'padding:4px 12px;font-weight:800;color:#ffd23e;font-size:13px;';
t.textContent = title;
pie.appendChild(t);
pie.appendChild(document.createElement('hr'));
}
for (const en of entries) {
if (en === '-') { pie.appendChild(document.createElement('hr')); continue; }
const d = document.createElement('div');
d.className = 'pi' + (en.disabled ? ' dis' : '');
d.innerHTML = `<span>${en.icon || ''}</span><span>${en.label}</span>` +
(en.price != null ? `<span class="price">${en.price < 0 ? '+' : ''}${fmtMoney(Math.abs(en.price)).slice(0)}</span>` : '');
if (!en.disabled) d.onclick = () => { hidePie(); AudioSys.sfx('click'); en.fn(); };
pie.appendChild(d);
}
pie.classList.remove('hidden');
// keep on-screen
const r = pie.getBoundingClientRect();
pie.style.left = clamp(px, 6, window.innerWidth - r.width - 8) + 'px';
pie.style.top = clamp(py, 6, window.innerHeight - r.height - 8) + 'px';
}
function hidePie() { document.getElementById('pieMenu').classList.add('hidden'); }
window.addEventListener('mousedown', (e) => {
const pie = document.getElementById('pieMenu');
if (!pie.classList.contains('hidden') && !pie.contains(e.target)) hidePie();
});
/* ---------------- interactions pie for an object ---------------- */
function objectInteractions(obj) {
const def = OBJECTS[obj.defId];
const entries = [];
if (obj.broken) {
entries.push({
label: 'Repair', icon: '🔧',
disabled: (G.selectedSim?.skills.mechanical || 0) < 1,
fn: () => {
const s = G.selectedSim;
if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; }
const mech = s.skills.mechanical || 0;
commandUse(s, obj, { id:'repair', label:'Repair', icon:'🔧', special:'repair',
pose:'stand', dur: Math.max(14, 50 - mech * 4) });
},
});
return entries;
}
for (const inter of def.interactions || []) {
if (inter.requiresDirty && obj.dirty <= .2) continue;
if (inter.requiresFull && obj.dirty < .5) continue;
if (inter.special === 'findJob' && G.selectedSim?.job) continue;
if (inter.special === 'tryBaby' && !canTryForBaby(G.selectedSim)) continue;
if (inter.babyOnly && G.selectedSim?.ageStage !== 'baby') continue;
if (inter.childOnly && G.selectedSim?.ageStage !== 'child') continue;
entries.push({
label: inter.label, icon: inter.icon,
disabled: !!(inter.cost && G.funds < inter.cost),
fn: () => {
const s = G.selectedSim;
if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; }
commandUse(s, obj, inter);
},
});
}
// sinks grow a Wash Dishes action when there are dirty dishes around
if (obj.defId === 'sink' && dishTotal() > 0) {
entries.push({
label: 'Wash Dishes', icon: '🧼',
fn: () => {
const s = G.selectedSim;
if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; }
commandUse(s, obj, { id:'wash', label:'Wash Dishes', icon:'🧼', special:'wash', pose:'stand', dur:40 });
},
});
}
return entries;
}
let keyIsShift = false;
/* ---------------- career picker (computer) ---------------- */
function jobMenuOpenFor(sim) { return G._jobMenuSim === sim && !document.getElementById('pieMenu').classList.contains('hidden'); }
function openJobPicker(sim, px, py) {
G._jobMenuSim = sim;
const entries = [];
for (const c of CAREERS) {
const r0 = c.ranks[0];
entries.push({
label: `${c.trackName}${r0.title}`, icon: c.icon,
fn: () => {
G._jobMenuSim = null;
CareerSys.hire(sim, c.id);
if (sim.action?.special === 'findJob') sim.action.finish();
updateSimPanel();
},
});
}
entries.push('-', { label: 'Never mind', icon: '↩️', fn: () => {
G._jobMenuSim = null;
if (sim.action?.special === 'findJob') sim.action.finish();
}});
showPie(px, py, entries, '📋 Choose a career track');
}
/* ---------------- help / mute ---------------- */
document.getElementById('btnHelp').onclick = () =>
document.getElementById('helpOverlay').classList.remove('hidden');
document.getElementById('helpClose').onclick = () =>
document.getElementById('helpOverlay').classList.add('hidden');
document.getElementById('btnHood').onclick = function () {
if (typeof enterHood === 'function') G.mode === 'hood' ? exitHood() : enterHood();
};
document.getElementById('btnMute').onclick = function () { AudioSys.muted = !AudioSys.muted;
this.classList.toggle('active', !AudioSys.muted);
this.textContent = AudioSys.muted ? '🔇' : '🔊';
};
/* ---------------- speed buttons ---------------- */
document.querySelectorAll('.speed-btn').forEach(b => {
b.onclick = () => setSpeed(+b.dataset.speed);
});
function setSpeed(v) {
G.speed = v;
document.querySelectorAll('.speed-btn').forEach(b =>
b.classList.toggle('active', +b.dataset.speed === v));
}
+342
View File
@@ -0,0 +1,342 @@
/* ============================================================
* world.js — Lot: tiles, walls, object placement, pathfinding
* ============================================================ */
'use strict';
const ekey = (x, y, e) => `${x},${y},${e}`; // wall edge key ('n' | 'w')
const ckey = (x, y) => x + ',' + y;
const DIAGS = [{ dx: 1, dy: 1 }, { dx: -1, dy: 1 }, { dx: 1, dy: -1 }, { dx: -1, dy: -1 }];
class World {
constructor(w = LOT_W, h = LOT_H) {
this.w = w; this.h = h;
this.floor = new Array(w * h).fill(0); // index into FLOORS (0=grass)
this.walls = new Map(); // ekey -> {kind:'wall'|'door'|'window', color}
this.objects = []; // placed GameObjects
this.cellObj = new Map(); // ckey -> object
this.roomScore = new Array(w * h).fill(50); // environment score per tile
this.dirtPuddle = []; // fading puddles
this.mailbox = { x: Math.floor(w / 2), y: h - 1 }; // visual + bills flavor
}
inside(x, y) { return x >= 0 && y >= 0 && x < this.w && y < this.h; }
/* ---------------- walls ---------------- */
wallAt(x, y, e) { return this.walls.get(ekey(x, y, e)); }
/** Edge between two orthogonal neighbors */
sharedEdge(ax, ay, bx, by) {
if (bx === ax + 1) return { x: bx, y: by, e: 'w' };
if (bx === ax - 1) return { x: ax, y: ay, e: 'w' };
if (by === ay + 1) return { x: ax, y: by, e: 'n' };
if (by === ay - 1) return { x: ax, y: ay, e: 'n' };
return null;
}
edgeBlocked(ax, ay, bx, by) {
const ed = this.sharedEdge(ax, ay, bx, by);
if (!ed) return true;
const w = this.wallAt(ed.x, ed.y, ed.e);
return !!w && w.kind !== 'door';
}
placeWall(x, y, e, kind = 'wall', silent = false) {
if (!this.inside(x, y) && !(e === 'n' && y === this.h)) return false;
const cur = this.wallAt(x, y, e);
if (cur && cur.kind === kind && cur.color === G.wallColor) return false;
this.walls.set(ekey(x, y, e), { kind, color: G.wallColor });
if (!silent) Bus.emit('worldChanged');
return true;
}
removeWall(x, y, e) {
const k = ekey(x, y, e);
if (this.walls.has(k)) { this.walls.delete(k); Bus.emit('worldChanged'); return true; }
return false;
}
setFloor(x, y, fid) {
if (!this.inside(x, y)) return;
this.floor[y * this.w + x] = fid;
Bus.emit('worldChanged');
}
/* ---------------- objects ---------------- */
objCells(obj) {
const cells = [];
for (let dy = 0; dy < obj.h; dy++)
for (let dx = 0; dx < obj.w; dx++)
cells.push([obj.x + dx, obj.y + dy]);
return cells;
}
objAt(x, y) {
const o = this.cellObj.get(ckey(x, y));
return o || null;
}
canPlace(def, x, y, rot) {
const w = rot ? def.h : def.w, h = rot ? def.w : def.h;
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
const tx = x + dx, ty = y + dy;
if (!this.inside(tx, ty)) return false;
if (this.objAt(tx, ty)) return false;
// don't allow placing on a tile occupied by a sim body
for (const s of G.sims) {
if (!s.atHome) continue;
if (Math.round(s.x) === tx && Math.round(s.y) === ty) return false;
}
// wall objects must hug a wall edge behind them (mirror/painting)
if (def.wallObj) {
const hasWall = this.wallAt(tx, ty, 'n') || this.wallAt(tx - 1, ty, 'w') ||
this.wallAt(tx, ty + 1, 'n') || this.wallAt(tx + 1, ty, 'w');
if (!hasWall) return false;
}
}
return true;
}
placeObject(defId, x, y, rot = 0, opts = {}) {
const def = OBJECTS[defId];
if (!def) return null;
const obj = {
id: uid(), defId, x, y,
rot,
w: rot ? def.h : def.w,
h: rot ? def.w : def.h,
usedBy: null, // sim currently using
dirty: 0, // toilets / trash fill level 0..1
lightOn: false,
...opts,
};
this.objects.push(obj);
for (const [cx, cy] of this.objCells(obj)) this.cellObj.set(ckey(cx, cy), obj);
Bus.emit('worldChanged');
Bus.emit('objectsChanged');
return obj;
}
removeObject(obj) {
if (!obj) return;
this.objects = this.objects.filter(o => o !== obj);
for (const [cx, cy] of this.objCells(obj)) {
if (this.cellObj.get(ckey(cx, cy)) === obj) this.cellObj.delete(ckey(cx, cy));
}
if (obj.usedBy && obj.usedBy.action) obj.usedBy.cancelAction('Object sold');
Bus.emit('worldChanged');
Bus.emit('objectsChanged');
}
findFreeSpotNear(x, y, maxR = 8) {
for (let r = 1; r <= maxR; r++) {
const cands = [];
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
const tx = x + dx, ty = y + dy;
if (this.inside(tx, ty) && this.tileWalkable(tx, ty)) cands.push([tx, ty]);
}
if (cands.length) return choice(cands);
}
return null;
}
/** best standing tile adjacent to an object's footprint */
useSpotNear(obj, fromX, fromY) {
let best = null, bd = 1e9;
for (let dy = -1; dy <= obj.h; dy++) for (let dx = -1; dx <= obj.w; dx++) {
const onFootprint = dx >= 0 && dx < obj.w && dy >= 0 && dy < obj.h;
if (onFootprint) continue;
const tx = obj.x + dx, ty = obj.y + dy;
if (!this.inside(tx, ty) || !this.tileWalkable(tx, ty)) continue;
const d = dist2(tx, ty, fromX, fromY);
if (d < bd) { bd = d; best = [tx, ty]; }
}
return best;
}
findObjects(pred) { return this.objects.filter(pred); }
/* ---------------- walkability & pathfinding ---------------- */
tileWalkable(x, y) {
if (!this.inside(x, y)) return false;
if (this.objAt(x, y)) return false;
return true;
}
/** A* path from (sx,sy) to (tx,ty). 8-directional, no corner cutting.
* Returns array of [x,y] incl. endpoints, or null. */
findPath(sx, sy, tx, ty) {
sx = Math.round(sx); sy = Math.round(sy); tx = Math.round(tx); ty = Math.round(ty);
if (!this.inside(sx, sy)) return null;
if (!this.inside(tx, ty) || !this.tileWalkable(tx, ty)) {
const alt = this.findFreeSpotNear(tx, ty, 6);
if (!alt) return null;
tx = alt[0]; ty = alt[1];
}
if (sx === tx && sy === ty) return [[tx, ty]];
const open = [{ x: sx, y: sy, g: 0, f: Math.sqrt(dist2(sx, sy, tx, ty)), parent: null }];
const seen = new Map([[ckey(sx, sy), 0]]);
let goal = null, guard = 0;
while (open.length && guard++ < 9000) {
let bi = 0;
for (let i = 1; i < open.length; i++) if (open[i].f < open[bi].f) bi = i;
const n = open.splice(bi, 1)[0];
if (n.x === tx && n.y === ty) { goal = n; break; }
for (let di = 0; di < 8; di++) {
const diag = di >= 4;
const d = diag ? DIAGS[di - 4] : DIRS[di];
const nx = n.x + d.dx, ny = n.y + d.dy;
if (!this.inside(nx, ny) || !this.tileWalkable(nx, ny)) continue;
if (diag) {
// both orthogonal legs must be open (tiles + edges)
if (!this.tileWalkable(n.x + d.dx, n.y)) continue;
if (!this.tileWalkable(n.x, n.y + d.dy)) continue;
if (this.edgeBlocked(n.x, n.y, n.x + d.dx, n.y)) continue;
if (this.edgeBlocked(n.x, n.y, n.x, n.y + d.dy)) continue;
if (this.edgeBlocked(n.x + d.dx, n.y, nx, ny)) continue;
if (this.edgeBlocked(n.x, n.y + d.dy, nx, ny)) continue;
} else if (this.edgeBlocked(n.x, n.y, nx, ny)) continue;
const g = n.g + (diag ? 1.45 : 1);
const k = ckey(nx, ny);
if (seen.has(k) && seen.get(k) <= g) continue;
seen.set(k, g);
open.push({ x: nx, y: ny, g, f: g + Math.sqrt(dist2(nx, ny, tx, ty)), parent: n });
}
}
if (!goal) return null;
const path = [];
for (let n = goal; n; n = n.parent) path.unshift([n.x, n.y]);
return path;
}
/* ---------------- environment score ---------------- */
recomputeRoom() {
const W = this.w, H = this.h;
this.roomScore.fill(28); // bare-lot baseline
// floors & walls make rooms feel finished
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
let s = 24;
if (this.floor[y * W + x] > 0) s += 14;
if (this.wallAt(x, y, 'n')) s += 5;
if (this.wallAt(x, y, 'w')) s += 5;
this.roomScore[y * W + x] += s * 0.35;
}
// object auras
for (const o of this.objects) {
const def = OBJECTS[o.defId];
const env = def.env || 0;
if (!env) continue;
const cx = o.x + o.w / 2, cy = o.y + o.h / 2, R = 6;
for (let y = Math.max(0, Math.floor(cy - R)); y <= Math.min(H - 1, cy + R); y++)
for (let x = Math.max(0, Math.floor(cx - R)); x <= Math.min(W - 1, cx + R); x++) {
const d = Math.sqrt(dist2(x + .5, y + .5, cx, cy));
if (d > R) continue;
this.roomScore[y * W + x] += env * (1 - d / R) * 0.9;
}
}
// dirt stinks
for (const o of this.objects) {
if ((o.defId === 'trash' || o.defId === 'toilet') && o.dirty > 0.5) {
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
const x = o.x + dx, y = o.y + dy;
if (this.inside(x, y))
this.roomScore[y * W + x] -= (1 - Math.sqrt(dx * dx + dy * dy) / 4) * 22 * o.dirty;
}
}
}
// dirty dishes stink up the place too
const dTotal = (typeof dishTotal === 'function') ? dishTotal() : 0;
if (dTotal > 0) {
const penalty = Math.min(26, dTotal * 2.2);
for (const p of G.dishPiles) {
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
const x = p.x + dx, y = p.y + dy;
if (this.inside(x, y))
this.roomScore[y * W + x] -= penalty * Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / 4) / Math.max(1, p.n);
}
}
}
for (let i = 0; i < this.roomScore.length; i++)
this.roomScore[i] = clamp(this.roomScore[i], 0, 100);
}
roomAt(x, y) {
x = clamp(Math.round(x), 0, this.w - 1); y = clamp(Math.round(y), 0, this.h - 1);
return this.roomScore[y * this.w + x];
}
serialize() {
return {
w: this.w, h: this.h,
floor: Array.from(this.floor),
walls: Array.from(this.walls.entries()),
roomScore: Array.from(this.roomScore),
objects: this.objects.map(o => ({ id:o.id, defId:o.defId, x:o.x, y:o.y, rot:o.rot, dirty:o.dirty, broken:!!o.broken, groceries:o.groceries||0 })),
mailbox: this.mailbox,
};
}
static deserialize(d) {
const wd = new World(d.w, d.h);
wd.floor = d.floor.slice();
wd.walls = new Map(d.walls);
wd.roomScore = d.roomScore ? d.roomScore.slice() : wd.roomScore;
wd.mailbox = d.mailbox;
for (const od of d.objects) {
const def = OBJECTS[od.defId]; if (!def) continue;
const o = wd.placeObject(od.defId, od.x, od.y, od.rot);
if (o) { o.dirty = od.dirty || 0; o.broken = !!od.broken; if (od.groceries) o.groceries = od.groceries; }
}
return wd;
}
}
/* ============================================================
* Starter house — cozy 1-bed bungalow so play starts instantly
* ============================================================ */
function buildStarterHouse(world) {
const X0 = 11, Y0 = 10, Wd = 11, Ht = 9; // outer rect
// floors: wood main, tile bath/kitchen
for (let y = Y0; y < Y0 + Ht; y++)
for (let x = X0; x < X0 + Wd; x++)
world.setFloor(x, y, 1);
// walls — outer shell sits ON the boundary lines around tiles [X0..X0+Wd-1]×[Y0..Y0+Ht-1]
for (let x = X0; x < X0 + Wd; x++) {
world.placeWall(x, Y0, 'n', 'wall', true); // north (top)
world.placeWall(x, Y0 + Ht, 'n', 'wall', true); // south (bottom)
}
for (let y = Y0; y < Y0 + Ht; y++) {
world.placeWall(X0, y, 'w', 'wall', true); // west (left)
world.placeWall(X0 + Wd, y, 'w', 'wall', true); // east (right)
}
// interior walls: bath top-left (3x4), bedroom right side
const BX = X0, BY = Y0, BW = 3, BH = 4; // bathroom zone
for (let x = BX; x < BX + BW; x++) world.placeWall(x, BY + BH, 'n', 'wall', true);
for (let y = BY; y < BY + BH; y++) world.placeWall(BX + BW, y, 'w', 'wall', true);
const RX = X0 + 7; // bedroom divider
for (let y = Y0 + 4; y < Y0 + Ht; y++) world.placeWall(RX, y, 'w', 'wall', true);
// doors: front door south center, bath door, bedroom door
world.placeWall(X0 + 5, Y0 + Ht, 'n', 'door', true);
world.placeWall(BX + 1, BY + BH, 'n', 'door', true);
world.placeWall(RX, Y0 + 5, 'w', 'door', true);
// windows
for (const [wx, wy, we] of [[X0 + 3, Y0, 'n'], [X0 + 7, Y0, 'n'], [X0, Y0 + 6, 'w'], [X0 + Wd, Y0 + 2, 'w']])
world.placeWall(wx, wy, we, 'window', true);
const P = (id, x, y, rot = 0) => world.placeObject(id, x, y, rot);
// bathroom
P('toilet', X0, Y0); P('shower', X0 + 1, Y0); P('sink', X0 + 2, Y0);
P('mirror', X0 + 2, Y0 + 1);
// bedroom (cols 19..21)
P('bedDouble', X0 + 8, Y0 + 6); P('lamp', X0 + 8, Y0 + 4);
// kitchen along bottom, gaps at x=X0+3 and x=X0+6 keep lanes open
P('fridge', X0, Y0 + Ht - 1); P('stove', X0 + 1, Y0 + Ht - 1);
P('counter', X0 + 2, Y0 + Ht - 1);
P('trash', X0 + 4, Y0 + Ht - 1);
// dining
P('table', X0 + 4, Y0 + 6); P('chair', X0 + 3, Y0 + 6, 1); P('chair', X0 + 4, Y0 + 5);
// living room (west), TV tucked along south wall with open approach
P('tv', X0, Y0 + 7);
P('sofa', X0 + 2, Y0 + 7, 1);
P('coffeeTable', X0, Y0 + 5);
P('bookshelf', X0 + 6, Y0 + 1); P('phone', X0 + 3, Y0 + 1);
P('plant', X0 + 7, Y0 + 3); P('lamp', X0, Y0 + 4);
// outside decor
P('plant', X0 - 1, Y0 + Ht); P('plant', X0 + Wd, Y0 + Ht);
// paint bath + kitchen tile
for (let y = Y0; y < Y0 + 4; y++) for (let x = X0; x < X0 + 3; x++) world.setFloor(x, y, 2);
for (let x = X0; x <= X0 + 4; x++) world.setFloor(x, Y0 + Ht - 1, 2);
// mailbox by the front walk
world.mailbox = { x: X0 + 6, y: Y0 + Ht + 2 };
world.recomputeRoom();
}