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
+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)),
};
}