Files

378 lines
15 KiB
JavaScript

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