/* ============================================================ * 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)), }; }