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:
+339
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user