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