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