/* ============================================================ * world.js — Lot: tiles, walls, object placement, pathfinding * ============================================================ */ 'use strict'; const ekey = (x, y, e) => `${x},${y},${e}`; // wall edge key ('n' | 'w') const ckey = (x, y) => x + ',' + y; const DIAGS = [{ dx: 1, dy: 1 }, { dx: -1, dy: 1 }, { dx: 1, dy: -1 }, { dx: -1, dy: -1 }]; class World { constructor(w = LOT_W, h = LOT_H) { this.w = w; this.h = h; this.floor = new Array(w * h).fill(0); // index into FLOORS (0=grass) this.walls = new Map(); // ekey -> {kind:'wall'|'door'|'window', color} this.objects = []; // placed GameObjects this.cellObj = new Map(); // ckey -> object this.roomScore = new Array(w * h).fill(50); // environment score per tile this.dirtPuddle = []; // fading puddles this.mailbox = { x: Math.floor(w / 2), y: h - 1 }; // visual + bills flavor } inside(x, y) { return x >= 0 && y >= 0 && x < this.w && y < this.h; } /* ---------------- walls ---------------- */ wallAt(x, y, e) { return this.walls.get(ekey(x, y, e)); } /** Edge between two orthogonal neighbors */ sharedEdge(ax, ay, bx, by) { if (bx === ax + 1) return { x: bx, y: by, e: 'w' }; if (bx === ax - 1) return { x: ax, y: ay, e: 'w' }; if (by === ay + 1) return { x: ax, y: by, e: 'n' }; if (by === ay - 1) return { x: ax, y: ay, e: 'n' }; return null; } edgeBlocked(ax, ay, bx, by) { const ed = this.sharedEdge(ax, ay, bx, by); if (!ed) return true; const w = this.wallAt(ed.x, ed.y, ed.e); return !!w && w.kind !== 'door'; } placeWall(x, y, e, kind = 'wall', silent = false) { if (!this.inside(x, y) && !(e === 'n' && y === this.h)) return false; const cur = this.wallAt(x, y, e); if (cur && cur.kind === kind && cur.color === G.wallColor) return false; this.walls.set(ekey(x, y, e), { kind, color: G.wallColor }); if (!silent) Bus.emit('worldChanged'); return true; } removeWall(x, y, e) { const k = ekey(x, y, e); if (this.walls.has(k)) { this.walls.delete(k); Bus.emit('worldChanged'); return true; } return false; } setFloor(x, y, fid) { if (!this.inside(x, y)) return; this.floor[y * this.w + x] = fid; Bus.emit('worldChanged'); } /* ---------------- objects ---------------- */ objCells(obj) { const cells = []; for (let dy = 0; dy < obj.h; dy++) for (let dx = 0; dx < obj.w; dx++) cells.push([obj.x + dx, obj.y + dy]); return cells; } objAt(x, y) { const o = this.cellObj.get(ckey(x, y)); return o || null; } canPlace(def, x, y, rot) { const w = rot ? def.h : def.w, h = rot ? def.w : def.h; for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) { const tx = x + dx, ty = y + dy; if (!this.inside(tx, ty)) return false; if (this.objAt(tx, ty)) return false; // don't allow placing on a tile occupied by a sim body for (const s of G.sims) { if (!s.atHome) continue; if (Math.round(s.x) === tx && Math.round(s.y) === ty) return false; } // wall objects must hug a wall edge behind them (mirror/painting) if (def.wallObj) { const hasWall = this.wallAt(tx, ty, 'n') || this.wallAt(tx - 1, ty, 'w') || this.wallAt(tx, ty + 1, 'n') || this.wallAt(tx + 1, ty, 'w'); if (!hasWall) return false; } } return true; } placeObject(defId, x, y, rot = 0, opts = {}) { const def = OBJECTS[defId]; if (!def) return null; const obj = { id: uid(), defId, x, y, rot, w: rot ? def.h : def.w, h: rot ? def.w : def.h, usedBy: null, // sim currently using dirty: 0, // toilets / trash fill level 0..1 lightOn: false, ...opts, }; this.objects.push(obj); for (const [cx, cy] of this.objCells(obj)) this.cellObj.set(ckey(cx, cy), obj); Bus.emit('worldChanged'); Bus.emit('objectsChanged'); return obj; } removeObject(obj) { if (!obj) return; this.objects = this.objects.filter(o => o !== obj); for (const [cx, cy] of this.objCells(obj)) { if (this.cellObj.get(ckey(cx, cy)) === obj) this.cellObj.delete(ckey(cx, cy)); } if (obj.usedBy && obj.usedBy.action) obj.usedBy.cancelAction('Object sold'); Bus.emit('worldChanged'); Bus.emit('objectsChanged'); } findFreeSpotNear(x, y, maxR = 8) { for (let r = 1; r <= maxR; r++) { const cands = []; for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; const tx = x + dx, ty = y + dy; if (this.inside(tx, ty) && this.tileWalkable(tx, ty)) cands.push([tx, ty]); } if (cands.length) return choice(cands); } return null; } /** best standing tile adjacent to an object's footprint */ useSpotNear(obj, fromX, fromY) { let best = null, bd = 1e9; for (let dy = -1; dy <= obj.h; dy++) for (let dx = -1; dx <= obj.w; dx++) { const onFootprint = dx >= 0 && dx < obj.w && dy >= 0 && dy < obj.h; if (onFootprint) continue; const tx = obj.x + dx, ty = obj.y + dy; if (!this.inside(tx, ty) || !this.tileWalkable(tx, ty)) continue; const d = dist2(tx, ty, fromX, fromY); if (d < bd) { bd = d; best = [tx, ty]; } } return best; } findObjects(pred) { return this.objects.filter(pred); } /* ---------------- walkability & pathfinding ---------------- */ tileWalkable(x, y) { if (!this.inside(x, y)) return false; if (this.objAt(x, y)) return false; return true; } /** A* path from (sx,sy) to (tx,ty). 8-directional, no corner cutting. * Returns array of [x,y] incl. endpoints, or null. */ findPath(sx, sy, tx, ty) { sx = Math.round(sx); sy = Math.round(sy); tx = Math.round(tx); ty = Math.round(ty); if (!this.inside(sx, sy)) return null; if (!this.inside(tx, ty) || !this.tileWalkable(tx, ty)) { const alt = this.findFreeSpotNear(tx, ty, 6); if (!alt) return null; tx = alt[0]; ty = alt[1]; } if (sx === tx && sy === ty) return [[tx, ty]]; const open = [{ x: sx, y: sy, g: 0, f: Math.sqrt(dist2(sx, sy, tx, ty)), parent: null }]; const seen = new Map([[ckey(sx, sy), 0]]); let goal = null, guard = 0; while (open.length && guard++ < 9000) { let bi = 0; for (let i = 1; i < open.length; i++) if (open[i].f < open[bi].f) bi = i; const n = open.splice(bi, 1)[0]; if (n.x === tx && n.y === ty) { goal = n; break; } for (let di = 0; di < 8; di++) { const diag = di >= 4; const d = diag ? DIAGS[di - 4] : DIRS[di]; const nx = n.x + d.dx, ny = n.y + d.dy; if (!this.inside(nx, ny) || !this.tileWalkable(nx, ny)) continue; if (diag) { // both orthogonal legs must be open (tiles + edges) if (!this.tileWalkable(n.x + d.dx, n.y)) continue; if (!this.tileWalkable(n.x, n.y + d.dy)) continue; if (this.edgeBlocked(n.x, n.y, n.x + d.dx, n.y)) continue; if (this.edgeBlocked(n.x, n.y, n.x, n.y + d.dy)) continue; if (this.edgeBlocked(n.x + d.dx, n.y, nx, ny)) continue; if (this.edgeBlocked(n.x, n.y + d.dy, nx, ny)) continue; } else if (this.edgeBlocked(n.x, n.y, nx, ny)) continue; const g = n.g + (diag ? 1.45 : 1); const k = ckey(nx, ny); if (seen.has(k) && seen.get(k) <= g) continue; seen.set(k, g); open.push({ x: nx, y: ny, g, f: g + Math.sqrt(dist2(nx, ny, tx, ty)), parent: n }); } } if (!goal) return null; const path = []; for (let n = goal; n; n = n.parent) path.unshift([n.x, n.y]); return path; } /* ---------------- environment score ---------------- */ recomputeRoom() { const W = this.w, H = this.h; this.roomScore.fill(28); // bare-lot baseline // floors & walls make rooms feel finished for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { let s = 24; if (this.floor[y * W + x] > 0) s += 14; if (this.wallAt(x, y, 'n')) s += 5; if (this.wallAt(x, y, 'w')) s += 5; this.roomScore[y * W + x] += s * 0.35; } // object auras for (const o of this.objects) { const def = OBJECTS[o.defId]; const env = def.env || 0; if (!env) continue; const cx = o.x + o.w / 2, cy = o.y + o.h / 2, R = 6; for (let y = Math.max(0, Math.floor(cy - R)); y <= Math.min(H - 1, cy + R); y++) for (let x = Math.max(0, Math.floor(cx - R)); x <= Math.min(W - 1, cx + R); x++) { const d = Math.sqrt(dist2(x + .5, y + .5, cx, cy)); if (d > R) continue; this.roomScore[y * W + x] += env * (1 - d / R) * 0.9; } } // dirt stinks for (const o of this.objects) { if ((o.defId === 'trash' || o.defId === 'toilet') && o.dirty > 0.5) { for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { const x = o.x + dx, y = o.y + dy; if (this.inside(x, y)) this.roomScore[y * W + x] -= (1 - Math.sqrt(dx * dx + dy * dy) / 4) * 22 * o.dirty; } } } // dirty dishes stink up the place too const dTotal = (typeof dishTotal === 'function') ? dishTotal() : 0; if (dTotal > 0) { const penalty = Math.min(26, dTotal * 2.2); for (const p of G.dishPiles) { for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { const x = p.x + dx, y = p.y + dy; if (this.inside(x, y)) this.roomScore[y * W + x] -= penalty * Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / 4) / Math.max(1, p.n); } } } for (let i = 0; i < this.roomScore.length; i++) this.roomScore[i] = clamp(this.roomScore[i], 0, 100); } roomAt(x, y) { x = clamp(Math.round(x), 0, this.w - 1); y = clamp(Math.round(y), 0, this.h - 1); return this.roomScore[y * this.w + x]; } serialize() { return { w: this.w, h: this.h, floor: Array.from(this.floor), walls: Array.from(this.walls.entries()), roomScore: Array.from(this.roomScore), objects: this.objects.map(o => ({ id:o.id, defId:o.defId, x:o.x, y:o.y, rot:o.rot, dirty:o.dirty, broken:!!o.broken, groceries:o.groceries||0 })), mailbox: this.mailbox, }; } static deserialize(d) { const wd = new World(d.w, d.h); wd.floor = d.floor.slice(); wd.walls = new Map(d.walls); wd.roomScore = d.roomScore ? d.roomScore.slice() : wd.roomScore; wd.mailbox = d.mailbox; for (const od of d.objects) { const def = OBJECTS[od.defId]; if (!def) continue; const o = wd.placeObject(od.defId, od.x, od.y, od.rot); if (o) { o.dirty = od.dirty || 0; o.broken = !!od.broken; if (od.groceries) o.groceries = od.groceries; } } return wd; } } /* ============================================================ * Starter house — cozy 1-bed bungalow so play starts instantly * ============================================================ */ function buildStarterHouse(world) { const X0 = 11, Y0 = 10, Wd = 11, Ht = 9; // outer rect // floors: wood main, tile bath/kitchen for (let y = Y0; y < Y0 + Ht; y++) for (let x = X0; x < X0 + Wd; x++) world.setFloor(x, y, 1); // walls — outer shell sits ON the boundary lines around tiles [X0..X0+Wd-1]×[Y0..Y0+Ht-1] for (let x = X0; x < X0 + Wd; x++) { world.placeWall(x, Y0, 'n', 'wall', true); // north (top) world.placeWall(x, Y0 + Ht, 'n', 'wall', true); // south (bottom) } for (let y = Y0; y < Y0 + Ht; y++) { world.placeWall(X0, y, 'w', 'wall', true); // west (left) world.placeWall(X0 + Wd, y, 'w', 'wall', true); // east (right) } // interior walls: bath top-left (3x4), bedroom right side const BX = X0, BY = Y0, BW = 3, BH = 4; // bathroom zone for (let x = BX; x < BX + BW; x++) world.placeWall(x, BY + BH, 'n', 'wall', true); for (let y = BY; y < BY + BH; y++) world.placeWall(BX + BW, y, 'w', 'wall', true); const RX = X0 + 7; // bedroom divider for (let y = Y0 + 4; y < Y0 + Ht; y++) world.placeWall(RX, y, 'w', 'wall', true); // doors: front door south center, bath door, bedroom door world.placeWall(X0 + 5, Y0 + Ht, 'n', 'door', true); world.placeWall(BX + 1, BY + BH, 'n', 'door', true); world.placeWall(RX, Y0 + 5, 'w', 'door', true); // windows for (const [wx, wy, we] of [[X0 + 3, Y0, 'n'], [X0 + 7, Y0, 'n'], [X0, Y0 + 6, 'w'], [X0 + Wd, Y0 + 2, 'w']]) world.placeWall(wx, wy, we, 'window', true); const P = (id, x, y, rot = 0) => world.placeObject(id, x, y, rot); // bathroom P('toilet', X0, Y0); P('shower', X0 + 1, Y0); P('sink', X0 + 2, Y0); P('mirror', X0 + 2, Y0 + 1); // bedroom (cols 19..21) P('bedDouble', X0 + 8, Y0 + 6); P('lamp', X0 + 8, Y0 + 4); // kitchen along bottom, gaps at x=X0+3 and x=X0+6 keep lanes open P('fridge', X0, Y0 + Ht - 1); P('stove', X0 + 1, Y0 + Ht - 1); P('counter', X0 + 2, Y0 + Ht - 1); P('trash', X0 + 4, Y0 + Ht - 1); // dining P('table', X0 + 4, Y0 + 6); P('chair', X0 + 3, Y0 + 6, 1); P('chair', X0 + 4, Y0 + 5); // living room (west), TV tucked along south wall with open approach P('tv', X0, Y0 + 7); P('sofa', X0 + 2, Y0 + 7, 1); P('coffeeTable', X0, Y0 + 5); P('bookshelf', X0 + 6, Y0 + 1); P('phone', X0 + 3, Y0 + 1); P('plant', X0 + 7, Y0 + 3); P('lamp', X0, Y0 + 4); // outside decor P('plant', X0 - 1, Y0 + Ht); P('plant', X0 + Wd, Y0 + Ht); // paint bath + kitchen tile for (let y = Y0; y < Y0 + 4; y++) for (let x = X0; x < X0 + 3; x++) world.setFloor(x, y, 2); for (let x = X0; x <= X0 + 4; x++) world.setFloor(x, Y0 + Ht - 1, 2); // mailbox by the front walk world.mailbox = { x: X0 + 6, y: Y0 + Ht + 2 }; world.recomputeRoom(); }