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:
2026-08-23 07:02:13 +00:00
commit 90bcecbf4b
14 changed files with 6626 additions and 0 deletions
+342
View File
@@ -0,0 +1,342 @@
/* ============================================================
* 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();
}