343 lines
14 KiB
JavaScript
343 lines
14 KiB
JavaScript
/* ============================================================
|
||
* 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();
|
||
}
|