Files

998 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* render.js — isometric renderer: terrain, walls, furniture
* painters (procedural pixel art), sims, lighting, ghosts
* ============================================================ */
'use strict';
const R = {
canvas: null, ctx: null,
W: 0, H: 0,
time: 0, // real seconds accumulated for animations
};
function initRender(canvas) {
R.canvas = canvas;
R.ctx = canvas.getContext('2d');
resizeRender();
window.addEventListener('resize', resizeRender);
}
function resizeRender() {
if (!R.canvas) return;
R.W = R.canvas.width = window.innerWidth;
R.H = R.canvas.height = window.innerHeight;
}
/* ---------------- geometry helpers ---------------- */
function tileCornerPx(x, y) {
const [sx, sy] = isoToScreen(x, y);
return [sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y];
}
function diamondPath(ctx, x, y, z = 0) {
const p = [
tileCornerPx(x, y), // N corner (top)
tileCornerPx(x + 1, y), // E corner (right)
tileCornerPx(x + 1, y + 1), // S (bottom)
tileCornerPx(x, y + 1), // W (left)
];
ctx.beginPath();
ctx.moveTo(p[0][0], p[0][1] - z);
ctx.lineTo(p[1][0], p[1][1] - z);
ctx.lineTo(p[2][0], p[2][1] - z);
ctx.lineTo(p[3][0], p[3][1] - z);
ctx.closePath();
}
/** axis-aligned iso box anchored at cell (x,y) covering fw×fh tiles, height hp px */
function isoBoxPath(ctx, x, y, fw, fh, z0, z1) {
// corners in tile units
const pts = [[x, y], [x + fw, y], [x + fw, y + fh], [x, y + fh]];
const scr = pts.map(([px, py]) => {
const [sx, sy] = isoToScreen(px, py);
return [sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y];
});
// top face
ctx.beginPath();
ctx.moveTo(scr[0][0], scr[0][1] - z1);
ctx.lineTo(scr[1][0], scr[1][1] - z1);
ctx.lineTo(scr[2][0], scr[2][1] - z1);
ctx.lineTo(scr[3][0], scr[3][1] - z1);
ctx.closePath();
}
function shade(hex, f) {
const n = parseInt(hex.slice(1), 16);
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
}
/** draw a 3D box (top/left/right faces) in screen px around center point */
function box3d(ctx, cx, cy, wPx, dPx, hPx, colTop, colL, colR) {
// wPx along +x screen dir (right-down), dPx along +y (left-down)
const hx = wPx / 2, hy = dPx / 2;
const ux = (TW / 2) / TW, uy = TH / TW; // normalized iso dirs scaled later
const X = (dx, dy) => [cx + (dx - dy) * 0.5, cy + (dx + dy) * 0.25];
const A = X(-hx, -hy), B = X(hx, -hy), C = X(hx, hy), D = X(-hx, hy);
// top
ctx.fillStyle = colTop;
ctx.beginPath();
ctx.moveTo(A[0], A[1] - hPx); ctx.lineTo(B[0], B[1] - hPx);
ctx.lineTo(C[0], C[1] - hPx); ctx.lineTo(D[0], D[1] - hPx);
ctx.closePath(); ctx.fill();
// right face (B-C)
ctx.fillStyle = colR;
ctx.beginPath();
ctx.moveTo(B[0], B[1] - hPx); ctx.lineTo(C[0], C[1] - hPx);
ctx.lineTo(C[0], C[1]); ctx.lineTo(B[0], B[1]);
ctx.closePath(); ctx.fill();
// left face (D-C)
ctx.fillStyle = colL;
ctx.beginPath();
ctx.moveTo(D[0], D[1] - hPx); ctx.lineTo(C[0], C[1] - hPx);
ctx.lineTo(C[0], C[1]); ctx.lineTo(D[0], D[1]);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(20,16,28,.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
/* ============================================================
* MAIN DRAW
* ============================================================ */
function draw() {
const ctx = R.ctx;
R.time += G.dtReal;
ctx.clearRect(0, 0, R.W, R.H);
/* sky backdrop */
const skyGrad = ctx.createLinearGradient(0, 0, 0, R.H);
const nightness = getNightness();
skyGrad.addColorStop(0, mixColor('#7ec8f0', '#0a1030', nightness));
skyGrad.addColorStop(1, mixColor('#bfe3c0', '#141c44', nightness));
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, R.W, R.H);
if (!G.world) return;
/* lot backdrop */
if (!G.world) return;
drawGround(ctx);
drawBuildGrid(ctx);
/* depth-sorted entities */
const items = [];
collectWalls(items);
collectObjects(items);
collectFires(items);
collectGhosts(items);
collectSims(items);
collectFx(items);
items.sort((a, b) => a.depth - b.depth || a.sub - b.sub);
for (const it of items) it.fn(ctx);
drawGhost(ctx);
drawLighting(ctx);
drawWeather(ctx);
}
/* ---------------- weather overlay ---------------- */
let raindrops = null;
function drawWeather(ctx) {
if (!G.weather) return;
if (G.weather.flash > 0) {
ctx.fillStyle = `rgba(255,255,240,${clamp(G.weather.flash, 0, .8) * .55})`;
ctx.fillRect(0, 0, R.W, R.H);
}
if (G.weather.type !== 'rain') return;
// persistent light dim while raining
ctx.fillStyle = 'rgba(25,35,60,.14)';
ctx.fillRect(0, 0, R.W, R.H);
// raindrops
const want = Math.floor(R.W / 6);
if (!raindrops || raindrops.length !== want) {
raindrops = Array.from({ length: want }, () => ({
x: Math.random() * R.W, y: Math.random() * R.H,
v: 700 + Math.random() * 500, l: 10 + Math.random() * 12,
}));
}
const dt = G.dtReal;
ctx.strokeStyle = 'rgba(180,205,235,.5)';
ctx.lineWidth = 1;
ctx.beginPath();
for (const d of raindrops) {
d.y += d.v * dt; d.x -= d.v * dt * .18;
if (d.y > R.H) { d.y = -20; d.x = Math.random() * (R.W + 200); }
if (d.x < -30) d.x += R.W + 60;
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - d.l * .18, d.y - d.l);
}
ctx.stroke();
}
/* ---------------- ground & floors ---------------- */
function drawGround(ctx) {
const W = G.world;
for (let y = 0; y < W.h; y++) {
for (let x = 0; x < W.w; x++) {
const fid = W.floor[y * W.w + x] ?? 0;
const f = FLOORS[fid] || FLOORS[0];
diamondPath(ctx, x, y);
const check = (x + y) % 2 === 0;
ctx.fillStyle = check ? f.c1 : f.c2;
ctx.fill();
// subtle inner edge
ctx.strokeStyle = 'rgba(0,0,0,.06)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// mailbox
const mb = W.mailbox;
const [mx, my] = tileCornerPx(mb.x + .5, mb.y + .9);
ctx.font = `${18 * G.cam.zoom}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('📮', mx, my - 14 * G.cam.zoom);
if (G.mailBillsDue && !G.billsPaid) {
ctx.font = `${13 * G.cam.zoom}px sans-serif`;
ctx.fillText('✉️', mx + 12 * G.cam.zoom, my - 22 * G.cam.zoom);
}
// dirt puddles & scorch marks
for (const p of G.world.dirtPuddle || []) {
diamondPath(ctx, p.x, p.y);
if (p.kind === 'scorch') {
const a = Math.min(.85, p.t / 800);
ctx.fillStyle = `rgba(28,24,22,${a})`;
} else if (p.kind === 'puke') {
ctx.fillStyle = `rgba(140,170,60,${Math.min(.8, p.t / 400)})`;
} else {
ctx.fillStyle = `rgba(110,80,40,${Math.min(.75, p.t / 300)})`;
}
ctx.fill();
}
}
function drawBuildGrid(ctx) {
if (G.mode !== 'build' && G.mode !== 'buy') return;
ctx.strokeStyle = 'rgba(255,255,255,.13)';
ctx.lineWidth = 1;
for (let y = 0; y <= G.world.h; y++) {
const a = tileCornerPx(0, y), b = tileCornerPx(G.world.w, y);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
for (let x = 0; x <= G.world.w; x++) {
const a = tileCornerPx(x, 0), b = tileCornerPx(x, G.world.h);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
}
/* ---------------- walls ---------------- */
function collectWalls(items) {
const W = G.world;
for (const [k, wall] of W.walls) {
const [x, y, e] = k.split(',');
const xi = +x, yi = +y;
const depth = xi + yi + (e === 'n' ? 0.02 : 0.03);
items.push({ depth, sub: 0, fn: (ctx) => drawWall(ctx, xi, yi, e, wall) });
}
}
function drawWall(ctx, x, y, e, wall) {
const z = WALL_H * G.cam.zoom;
const A = e === 'n' ? tileCornerPx(x, y) : tileCornerPx(x, y);
const B = e === 'n' ? tileCornerPx(x + 1, y) : tileCornerPx(x, y + 1);
const base = shade(wall.color || '#efe6d4', e === 'n' ? 1.0 : 0.82);
const lit = shade(wall.color || '#efe6d4', e === 'n' ? 1.12 : 0.92);
if (wall.kind === 'door' || wall.kind === 'window') {
// two posts + opening
const t1 = 0.14, t2 = 0.86;
seg(A, B, 0, t1); seg(A, B, t2, 1);
if (wall.kind === 'door') {
seg(A, B, t1, t2, true); // lintel across top
// door slab ajar
const dx = lerp(A[0], B[0], .5), dy = lerp(A[1], B[1], .5);
ctx.strokeStyle = '#6e4a26';
ctx.lineWidth = Math.max(2, 3 * G.cam.zoom);
ctx.beginPath();
ctx.moveTo(dx, dy - z);
ctx.lineTo(dx + 6 * G.cam.zoom, dy - z + 10 * G.cam.zoom);
ctx.stroke();
} else {
// window: bottom sill band + glass + top band
bandSeg(A, B, 0.05, z * .32);
bandSeg(A, B, 0.78, z * .22);
// glass
ctx.fillStyle = 'rgba(160,210,240,.45)';
const g0 = ptAt(A, B, t1), g1 = ptAt(A, B, t2);
ctx.beginPath();
ctx.moveTo(g0[0], g0[1] - z * .36); ctx.lineTo(g1[0], g1[1] - z * .36);
ctx.lineTo(g1[0], g1[1] - z * .74); ctx.lineTo(g0[0], g0[1] - z * .74);
ctx.closePath(); ctx.fill();
}
} else {
// solid wall
ctx.fillStyle = base;
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]);
ctx.lineTo(B[0], B[1] - z); ctx.lineTo(A[0], A[1] - z);
ctx.closePath(); ctx.fill();
// lit inner face hint
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]);
ctx.lineTo(B[0], B[1] - z * .25); ctx.lineTo(A[0], A[1] - z * .25);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(30,22,15,.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(A[0], A[1] - z); ctx.lineTo(B[0], B[1] - z); ctx.lineTo(B[0], B[1]);
ctx.stroke();
}
function seg(P, Q, t0, t1, topOnly = false) {
const p0 = ptAt(P, Q, t0), p1 = ptAt(P, Q, t1);
ctx.fillStyle = base;
ctx.beginPath();
if (!topOnly) {
ctx.moveTo(p0[0], p0[1]); ctx.lineTo(p1[0], p1[1]);
ctx.lineTo(p1[0], p1[1] - z); ctx.lineTo(p0[0], p0[1] - z);
} else {
ctx.moveTo(p0[0], p0[1] - z); ctx.lineTo(p1[0], p1[1] - z);
ctx.lineTo(p1[0], p1[1] - z * .8); ctx.lineTo(p0[0], p0[1] - z * .8);
}
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(30,22,15,.4)';
ctx.stroke();
}
function bandSeg(P, Q, hFrac, hh) {
const yy = P[1] - hh;
const y1 = Q[1] - hh;
ctx.fillStyle = lit;
ctx.fillRect(Math.min(P[0], Q[0]), Math.min(yy, y1) , Math.abs(Q[0] - P[0]) + 2, Math.abs(hh) );
}
function ptAt(P, Q, t) { return [lerp(P[0], Q[0], t), lerp(P[1], Q[1], t)]; }
}
/* ============================================================
* OBJECT PAINTERS — procedural pixel art, centered at anchor
* ============================================================ */
const PAINTERS = {
chair(ctx, o, t) { box3d(ctx, 0, 0, 30, 30, 16, '#a8743f', '#8a5c30', '#7a5028');
ctx.fillStyle = '#7a4f26'; ctx.fillRect(-14, -34, 28, 20); },
stool(ctx, o, t) { box3d(ctx, 0, 0, 24, 24, 12, '#b5824a', '#96683a', '#865a30'); },
sofa(ctx, o, t) {
const len = o.h * TH * .95;
box3d(ctx, 0, -len * .18, 52, len, 14, '#4f7fd0', '#3d63a8', '#35569a');
ctx.fillStyle = '#3d63a8'; ctx.fillRect(-24, -len * .95 - 16, 48, 22);
ctx.fillStyle = '#6fa0e8'; ctx.fillRect(-20, -len * .72, 40, 8);
ctx.fillStyle = '#6fa0e8'; ctx.fillRect(-20, -len * .38, 40, 8);
},
loveseat(ctx, o, t) {
const len = o.h * TH * .95;
box3d(ctx, 0, -len * .2, 46, len, 13, '#c05a8a', '#9c4770', '#8a3e62');
ctx.fillStyle = '#9c4770'; ctx.fillRect(-21, -len * .95 - 14, 42, 20);
},
table(ctx, o, t) { box3d(ctx, 0, -4, 54, 54, 20, '#c99b62', '#a87c48', '#98703f'); },
coffeeTable(ctx, o, t) { box3d(ctx, 0, -2, o.w * TW * .8, 34, 12, '#b5824a', '#96683a', '#7a5028'); },
desk(ctx, o, t) { box3d(ctx, 0, -4, o.w * TW * .85, 36, 22, '#a87c48', '#8a6438', '#7a562e'); },
bedSingle(ctx, o, t) { drawBed(ctx, o, '#d9d9e2', '#7f9fd9'); },
bedDouble(ctx, o, t) { drawBed(ctx, o, '#e8e2ef', '#c98aa8'); },
toilet(ctx, o, t) {
box3d(ctx, 0, 4, 30, 34, 14, '#f2f2f6', '#d8d8de', '#c8c8d0');
ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.ellipse(0, -2, 13, 9, 0, 0, 7); ctx.fill();
ctx.strokeStyle = '#b8b8c0'; ctx.stroke();
ctx.fillStyle = '#e8e8ee'; ctx.fillRect(-14, -30, 28, 18);
if (o.dirty > .3) { ctx.fillStyle = `rgba(140,110,60,${o.dirty * .5})`; ctx.fillRect(-12, -28, 24, 14); }
},
shower(ctx, o, t) {
box3d(ctx, 0, 6, 44, 44, 6, '#bcd8e8', '#9dbdd2', '#8fb0c6');
ctx.strokeStyle = '#aac8da'; ctx.lineWidth = 3;
ctx.strokeRect(-20, -52, 40, 52);
ctx.fillStyle = 'rgba(190,225,245,.35)'; ctx.fillRect(-20, -52, 40, 52);
ctx.fillStyle = '#8899aa'; ctx.fillRect(-6, -58, 12, 6);
if (o.usedBy) { ctx.fillStyle = 'rgba(200,235,255,.8)';
for (let i = 0; i < 5; i++) ctx.fillRect(-14 + i * 7, -46 + ((t * 60 + i * 13) % 40), 2, 6); }
},
bathtub(ctx, o, t) {
box3d(ctx, 0, -o.h * 6, 44, o.h * TH * .8, 18, '#eef4f8', '#ccd8e2', '#bcc8d2');
ctx.fillStyle = '#cfe6f2'; ctx.beginPath(); ctx.ellipse(0, -8, 15, 10, 0, 0, 7); ctx.fill();
},
sink(ctx, o, t) {
box3d(ctx, 0, 2, 36, 26, 16, '#f2f2f6', '#d8d8de', '#c8c8d0');
ctx.fillStyle = '#8899aa'; ctx.fillRect(-2, -26, 4, 10);
},
mirror(ctx, o, t) {
ctx.fillStyle = '#8a6438'; ctx.fillRect(-16, -58, 32, 40);
ctx.fillStyle = '#bfe3f2'; ctx.fillRect(-13, -55, 26, 34);
ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.fillRect(-13, -55, 8, 34);
},
fridge(ctx, o, t) {
box3d(ctx, 0, 0, 40, 36, 52, '#e8ecf2', '#ccd2dc', '#bcc2cc');
ctx.fillStyle = '#9aa4b0'; ctx.fillRect(12, -40, 3, 16); ctx.fillRect(12, -18, 3, 10);
ctx.fillStyle = '#ffd23e'; ctx.fillRect(-14, -34, 8, 6);
},
stove(ctx, o, t) {
box3d(ctx, 0, 0, 40, 36, 26, '#d8dce4', '#b8bec8', '#a8aeb8');
ctx.fillStyle = '#333844';
ctx.beginPath(); ctx.arc(-8, -22, 5, 0, 7); ctx.arc(8, -22, 5, 0, 7); ctx.fill();
if (o.usedBy) { ctx.fillStyle = `rgba(255,${100 + Math.sin(t * 9) * 60},40,.9)`;
ctx.beginPath(); ctx.arc(-8, -26, 4 + Math.sin(t * 13) * 2, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(8, -26, 4 + Math.cos(t * 11) * 2, 0, 7); ctx.fill(); }
},
counter(ctx, o, t) {
box3d(ctx, 0, 0, 52, 40, 22, '#c9a26a', '#a87c48', '#98703f');
ctx.fillStyle = '#e8e2d4'; ctx.fillRect(-24, -24, 48, 6);
},
trash(ctx, o, t) {
box3d(ctx, 0, 2, 26, 26, 18, '#7a8494', '#646e7e', '#586270');
if (o.dirty > .5) { ctx.font = '12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🪰', 8, -22 + Math.sin(t * 5) * 3); }
},
tv(ctx, o, t) {
box3d(ctx, 0, 4, o.w * TW * .8, 22, 10, '#4a4038', '#3a322c', '#332c27');
ctx.fillStyle = '#22201e'; ctx.fillRect(-o.w * TW * .35, -52, o.w * TW * .7, 40);
const on = !!o.usedBy;
if (on) {
const flick = [' #4a90e2', '#3ac05a', '#e2c04a'][Math.floor(t * 3) % 3];
ctx.fillStyle = flick.trim();
ctx.fillRect(-o.w * TW * .32, -49, o.w * TW * .64, 34);
ctx.fillStyle = 'rgba(255,255,255,.25)';
for (let i = 0; i < 4; i++)
ctx.fillRect(-o.w * TW * .3 + Math.random() * o.w * TW * .5, -47 + Math.random() * 28, 6, 3);
} else { ctx.fillStyle = '#101418'; ctx.fillRect(-o.w * TW * .32, -49, o.w * TW * .64, 34); }
},
stereo(ctx, o, t) {
box3d(ctx, 0, 0, 34, 26, 30, '#2e3038', '#23252c', '#1e2026');
ctx.fillStyle = o.usedBy ? '#43e05a' : '#445058'; ctx.beginPath(); ctx.arc(-7, -15, 6, 0, 7); ctx.fill();
ctx.fillStyle = o.usedBy ? '#43e05a' : '#445058'; ctx.beginPath(); ctx.arc(7, -15, 6, 0, 7); ctx.fill();
if (o.usedBy) { ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🎵', -14, -34 + Math.abs(Math.sin(t * 4)) * -8);
ctx.fillText('🎶', 14, -34 + Math.abs(Math.cos(t * 4)) * -8); }
},
computer(ctx, o, t) {
box3d(ctx, 0, 6, 40, 30, 16, '#d8d4c8', '#b8b4a8', '#a8a498');
ctx.fillStyle = '#2a2e38'; ctx.fillRect(-12, -44, 24, 20);
ctx.fillStyle = o.usedBy ? '#4ab8e2' : '#14181e'; ctx.fillRect(-10, -42, 20, 16);
if (o.usedBy) { ctx.fillStyle = 'rgba(255,255,255,.5)';
for (let i = 0; i < 3; i++) ctx.fillRect(-9 + i * 7, -41 + ((t * 30 + i * 5) % 13), 4, 2); }
ctx.fillStyle = '#3a3e48'; ctx.fillRect(-14, -24, 28, 4);
},
phone(ctx, o, t) {
box3d(ctx, 0, 4, 22, 20, 12, '#d94a4a', '#b83a3a', '#a83232');
ctx.fillStyle = '#fff'; ctx.fillRect(-6, -18, 12, 8);
},
bookshelf(ctx, o, t) {
box3d(ctx, 0, 0, 44, 26, 48, '#8a6438', '#704e28', '#62441f');
const cols = ['#c94a4a', '#4a72c9', '#43b05a', '#e8a33d', '#8a52c9'];
for (let row = 0; row < 3; row++)
for (let i = 0; i < 5; i++) {
ctx.fillStyle = cols[(i + row * 2) % cols.length];
ctx.fillRect(-17 + i * 7, -42 + row * 14, 5, 11);
}
},
easel(ctx, o, t) {
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(-14, 8); ctx.lineTo(0, -52); ctx.lineTo(14, 8); ctx.stroke();
ctx.fillStyle = '#f2ede2'; ctx.fillRect(-18, -48, 36, 28);
if (o.usedBy) {
ctx.fillStyle = ['#4a90e2','#e25a4a','#43b05a'][Math.floor(t) % 3];
ctx.beginPath(); ctx.arc(Math.sin(t * 3) * 10, -36 + Math.cos(t * 2) * 6, 4, 0, 7); ctx.fill();
}
},
treadmill(ctx, o, t) {
box3d(ctx, 0, 4, 34, 52, 8, '#3a4048', '#2e343a', '#282e34');
ctx.strokeStyle = '#5a6470'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(-12, 0); ctx.lineTo(-12, -40); ctx.lineTo(12, -40); ctx.stroke();
if (o.usedBy) { ctx.fillStyle = 'rgba(120,220,255,.6)';
ctx.fillRect(-14 + Math.sin(t * 8) * 3, -34, 4, 4); }
},
piano(ctx, o, t) {
box3d(ctx, 0, 0, o.w * TW * .85, 40, 26, '#2a2228', '#201a1f', '#18131a');
ctx.fillStyle = '#f2f2f2';
for (let i = 0; i < 10; i++) ctx.fillRect(-o.w * TW * .36 + i * 8, -14, 6, 12);
ctx.fillStyle = '#111';
for (let i = 0; i < 7; i++) ctx.fillRect(-o.w * TW * .34 + i * 11 + 4, -14, 4, 8);
if (o.usedBy) { ctx.font = '12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🎵', Math.sin(t * 3) * 16, -40 - Math.abs(Math.sin(t * 5)) * 8); }
},
chessboard(ctx, o, t) {
box3d(ctx, 0, -2, 46, 46, 18, '#a87c48', '#8a6438', '#7a562e');
ctx.fillStyle = '#e8dcc8'; ctx.fillRect(-16, -24, 32, 16);
ctx.fillStyle = '#5a4426';
for (let r = 0; r < 2; r++) for (let c = 0; c < 4; c++) ctx.fillRect(-16 + c * 8 + (r ? 4 : 0), -23 + r * 7, 4, 6);
ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('♟', -8, -28); ctx.fillText('♞', 8, -28);
},
crib(ctx, o, t) {
box3d(ctx, 0, 2, 40, 40, 16, '#e8dcc8', '#c9bda6', '#b8ac96');
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 3;
for (let i = 0; i < 5; i++) {
const xx = -16 + i * 8;
ctx.beginPath(); ctx.moveTo(xx, -34); ctx.lineTo(xx, -14); ctx.stroke();
}
ctx.fillStyle = '#c9a24a';
ctx.fillRect(-20, -38, 40, 5);
// sleeping baby bump when occupied
if (o.usedBy && o.usedBy.ageStage === 'baby') {
ctx.fillStyle = SKINS[o.usedBy.skin % SKINS.length];
ctx.beginPath(); ctx.arc(0, -22, 7, 0, 7); ctx.fill();
ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
if (chance(.02)) ctx.fillText('💤', 12, -30);
}
},
easel(ctx, o, t) {
// tripod legs
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(-14, 2); ctx.lineTo(0, -40);
ctx.moveTo(14, 2); ctx.lineTo(0, -40);
ctx.moveTo(0, 2); ctx.lineTo(0, -34);
ctx.stroke();
// canvas
ctx.fillStyle = '#f7f2e6'; ctx.fillRect(-13, -36, 26, 20);
ctx.strokeStyle = '#c9bda6'; ctx.lineWidth = 2; ctx.strokeRect(-13, -36, 26, 20);
// a dab of art
ctx.fillStyle = ['#e84a1a', '#3e6fa8', '#4a8a4a', '#ffd23e'][Math.floor(t / 2) % 4];
ctx.beginPath(); ctx.arc(-5 + (Math.sin(t) * 5), -27 + Math.cos(t * .7) * 4, 3.4, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(6, -30, 2.6, 0, 7); ctx.fill();
},
toybox(ctx, o, t) { box3d(ctx, 0, 2, 36, 28, 20, '#d94a4a', '#b83a3a', '#a83232');
ctx.fillStyle = '#ffd23e'; ctx.fillRect(-14, -24, 28, 5);
ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🪀', -9, -26); ctx.fillText('🧸', 9, -27);
},
gravestone(ctx, o, t) { box3d(ctx, 0, 2, 26, 12, 24, '#9aa2ab', '#7f878f', '#70787f');
ctx.fillStyle = '#6a727a'; ctx.fillRect(-6, -20, 12, 3);
ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('RIP', 0, -24);
if (chance(.02)) { ctx.font = '10px sans-serif'; ctx.fillText('🕯️', 10, -18); }
},
plant(ctx, o, t) {
box3d(ctx, 0, 4, 24, 24, 14, '#b5651e', '#964f18', '#864414');
ctx.fillStyle = '#3d8a3d';
ctx.beginPath(); ctx.ellipse(0, -22, 14, 16, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#4da34d';
ctx.beginPath(); ctx.ellipse(-5, -26, 8, 10, -.4, 0, 7); ctx.fill();
ctx.beginPath(); ctx.ellipse(6, -24, 7, 9, .4, 0, 7); ctx.fill();
},
lamp(ctx, o, t) {
ctx.fillStyle = '#5a5048'; ctx.fillRect(-2, -34, 4, 34);
ctx.fillStyle = o.lightOn ? '#ffe9a8' : '#d8cfb8';
ctx.beginPath(); ctx.moveTo(-12, -34); ctx.lineTo(12, -34); ctx.lineTo(8, -48); ctx.lineTo(-8, -48); ctx.closePath(); ctx.fill();
},
painting(ctx, o, t) {
ctx.fillStyle = '#8a6438'; ctx.fillRect(-16, -64, 32, 26);
ctx.fillStyle = ['#7fb2e2','#e2c07f','#9be27f'][o.id % 3];
ctx.fillRect(-13, -61, 26, 20);
ctx.fillStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.arc(-4, -53, 5, 0, 7); ctx.fill();
},
fountain(ctx, o, t) {
box3d(ctx, 0, 0, o.w * TW * .8, o.h * TH * 1.4, 14, '#c8ccd4', '#a8acb6', '#989ca6');
ctx.fillStyle = '#6fc0e8'; ctx.beginPath(); ctx.ellipse(0, -10, o.w * TW * .3, o.h * TH * .5, 0, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.6)';
const jh = 14 + Math.sin(t * 4) * 5;
ctx.fillRect(-2, -14 - jh, 4, jh);
ctx.font = `${12 * G.cam.zoom}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('💧', 6, -20 - jh);
},
};
function drawBed(ctx, o, sheetCol, blankCol) {
const len = o.h * TH * 1.05;
box3d(ctx, 0, -len * .12, o.w * TW * .78, len, 12, '#8a6438', '#704e28', '#62441f');
// mattress + pillow + blanket
ctx.fillStyle = sheetCol;
ctx.fillRect(-o.w * TW * .34, -len * .95, o.w * TW * .68, len * .8);
ctx.fillStyle = '#ffffff';
ctx.fillRect(-o.w * TW * .3, -len * .93, o.w * TW * .6, len * .18);
ctx.fillStyle = blankCol;
ctx.fillRect(-o.w * TW * .34, -len * .62, o.w * TW * .68, len * .45);
ctx.strokeStyle = 'rgba(30,20,10,.3)';
ctx.strokeRect(-o.w * TW * .34, -len * .95, o.w * TW * .68, len * .8);
}
function collectObjects(items) {
for (const o of G.world.objects) {
const def = OBJECTS[o.defId];
// draw at the deepest footprint cell so multi-tile objects sort correctly
const depth = (o.x + o.w - 1) + (o.y + o.h - 1) - 0.25;
items.push({
depth,
sub: 1,
fn: (ctx) => drawObject(ctx, o, def),
});
}
// dirty dish piles
for (const p of (G.dishPiles || [])) {
if (p.n <= 0) continue;
items.push({ depth: p.x + p.y + 0.3, sub: 3, fn: (ctx) => {
const [ax, ay] = isoToScreen(p.x, p.y);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
ctx.save();
ctx.translate(px, py);
ctx.scale(G.cam.zoom, G.cam.zoom);
const stacks = Math.min(4, Math.ceil(p.n));
for (let i = 0; i < stacks; i++) {
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🍽️', (i % 2 ? 8 : -7), -4 - Math.floor(i / 2) * 9);
}
if (p.n >= 3) { ctx.font = '10px sans-serif'; ctx.fillText('🪰', 12, -18 + Math.sin(R.time * 5) * 3); }
ctx.restore();
}});
}
}
function drawObject(ctx, o, def) {
const vcx = o.x + o.w / 2, vcy = o.y + o.h / 2;
const [px, py] = tileCornerPx(vcx - .5 + .5 * 0, vcy - .5);
// center of footprint on screen:
const [ax, ay] = isoToScreen(vcx, vcy);
const cx = ax * G.cam.zoom + G.cam.x;
const cy = ay * G.cam.zoom + G.cam.y;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(G.cam.zoom, G.cam.zoom);
// soft ground shadow
ctx.fillStyle = 'rgba(20,16,28,.18)';
ctx.beginPath();
ctx.ellipse(0, o.h * TH * .18, o.w * TW * .34, o.h * TH * .3, 0, 0, 7);
ctx.fill();
const painter = PAINTERS[def.shape];
if (painter) painter(ctx, o, R.time);
else { ctx.fillStyle = '#caa'; ctx.fillRect(-14, -30, 28, 30); }
if (o.broken) {
// sparks & smoke over broken objects
ctx.font = '13px sans-serif'; ctx.textAlign = 'center';
const jx = Math.sin(R.time * 23 + o.id) * 4, jy = -Math.abs(Math.cos(R.time * 17)) * 6;
ctx.fillText('⚡', jx, -46 - def.h * 8 + jy);
ctx.fillStyle = 'rgba(60,60,66,.5)';
ctx.beginPath();
ctx.arc(0, -40 - def.h * 8, 7 + Math.sin(R.time * 3) * 2, 0, 7);
ctx.fill();
}
ctx.restore();
// usage sparkle: show who uses it
if (o.usedBy && def.cat === 'electronics') { /* anim handled in painters */ }
}
/* ============================================================
* SIM SPRITES
* ============================================================ */
/**
* Draw a sim character at screen point (px,py)=feet position.
* opts: {zoom, facing, anim, animT, skin, hairStyle, hairColorIdx, shirt, pants, scale}
*/
function drawSimSprite(ctx, px, py, s, opts = {}) {
const zoom = opts.zoom ?? G.cam.zoom;
const stageK = s.ageStage === 'baby' ? .55 : s.ageStage === 'child' ? .78 : 1;
const sc = (opts.scale ?? 1) * zoom * stageK;
const t = opts.animT ?? 0;
const anim = s.ageStage === 'baby' ? 'idle' : (opts.anim ?? 'idle');
const facing = opts.facing ?? 0;
const skin = SKINS[s.skin % SKINS.length];
const hair = HAIRS[s.hairColor % HAIRS.length];
const shirt = SHIRTS[s.shirt % SHIRTS.length];
const pants = PANTS[s.pants % PANTS.length];
ctx.save();
ctx.translate(px, py);
ctx.scale(sc, sc);
if (facing === 1) ctx.scale(-1, 1); // W mirrors E
const back = facing === 2; // facing away (N)
const walking = anim === 'walk';
const swing = walking ? Math.sin(t * 11) : 0;
const bob = walking ? Math.abs(Math.sin(t * 11)) * 2 : (anim === 'dance' ? Math.abs(Math.sin(t * 6)) * 4 : Math.sin(t * 2.2) * .8);
const danceWave = anim === 'dance' ? Math.sin(t * 6) * 8 : 0;
const exercise = anim === 'exercise';
if (anim === 'lie') {
// lying down: horizontal body
ctx.fillStyle = 'rgba(20,16,28,.2)';
ctx.beginPath(); ctx.ellipse(0, 2, 26, 8, 0, 0, 7); ctx.fill();
ctx.fillStyle = pants; ctx.fillRect(-22, -10, 18, 9); // legs
ctx.fillStyle = shirt; ctx.fillRect(-4, -11, 22, 11); // torso
ctx.fillStyle = skin; ctx.beginPath(); ctx.arc(24, -8, 8, 0, 7); ctx.fill(); // head
ctx.fillStyle = hair; ctx.beginPath(); ctx.arc(24, -12, 8, Math.PI, 0); ctx.fill();
ctx.restore();
return;
}
const sitPose = anim === 'sit';
const legH = sitPose ? 8 : 14;
const bodyY = -(legH + 16) - bob;
// shadow
ctx.fillStyle = 'rgba(20,16,28,.25)';
ctx.beginPath(); ctx.ellipse(0, 1, 11, 4.5, 0, 0, 7); ctx.fill();
// legs
ctx.fillStyle = pants;
if (sitPose) {
ctx.fillRect(-8, -8, 6, 9); ctx.fillRect(2, -8, 6, 9);
ctx.fillRect(-8, -2, 16, 4); // shins forward
} else if (walking) {
ctx.fillRect(-7 + swing * 3, -14, 5, 14);
ctx.fillRect(2 - swing * 3, -14, 5, 14);
} else {
ctx.fillRect(-7, -14, 5, 14); ctx.fillRect(2, -14, 5, 14);
}
// torso
ctx.fillStyle = shirt;
ctx.fillRect(-8, bodyY, 16, sitPose ? 12 : 16);
// arms
ctx.fillStyle = shirt;
if (anim === 'dance') {
ctx.fillRect(-13, bodyY - 6 - danceWave * .5, 5, 14);
ctx.fillRect(8, bodyY - 6 + danceWave * .5, 5, 14);
} else if (exercise) {
ctx.fillRect(-12, bodyY + Math.sin(t * 9) * 4, 5, 13);
ctx.fillRect(7, bodyY - Math.sin(t * 9) * 4, 5, 13);
} else {
ctx.fillRect(-12, bodyY + 2, 5, sitPose ? 8 : 13);
ctx.fillRect(7, bodyY + 2, 5, sitPose ? 8 : 13);
}
// hands
ctx.fillStyle = skin;
ctx.fillRect(-12, bodyY + (sitPose ? 9 : 14), 5, 4);
ctx.fillRect(7, bodyY + (sitPose ? 9 : 14), 5, 4);
// head
const headY = bodyY - 9;
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(0, headY, 8.4, 0, 7); ctx.fill();
// hair styles: 0 short, 1 long, 2 ponytail, 3 spiky/bald-cap
ctx.fillStyle = hair;
const hs = s.hairStyle % 4;
ctx.beginPath();
if (back) ctx.arc(0, headY, 8.4, Math.PI * .95, Math.PI * 2.05);
else ctx.arc(0, headY - 1.5, 8.4, Math.PI, Math.PI * 2);
ctx.fill();
if (hs === 1) { ctx.fillRect(-9, headY - 2, 5, 14); ctx.fillRect(4, headY - 2, 5, 14); }
if (hs === 2) { ctx.beginPath(); ctx.arc(back ? 0 : 9, headY + (back ? -2 : 2), 4.4, 0, 7); ctx.fill(); }
if (hs === 3) { for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.moveTo(i * 5 - 2, headY - 7); ctx.lineTo(i * 5, headY - 13); ctx.lineTo(i * 5 + 2, headY - 7); ctx.fill(); } }
// face
if (!back) {
ctx.fillStyle = '#222';
const ex = facing === 1 ? -1 : 0;
ctx.fillRect(-4 + ex, headY - 1, 2, 2.6);
ctx.fillRect(2 + ex, headY - 1, 2, 2.6);
ctx.fillStyle = 'rgba(220,120,120,.5)';
ctx.fillRect(-6 + ex, headY + 2, 3, 2); ctx.fillRect(3 + ex, headY + 2, 3, 2);
}
// carried plate
if (s.carryPlate) {
ctx.fillStyle = '#f2f2f2'; ctx.beginPath(); ctx.ellipse(12, bodyY + 12, 6, 3, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#c9803d'; ctx.beginPath(); ctx.ellipse(12, bodyY + 11, 3.4, 2, 0, 0, 7); ctx.fill();
}
ctx.restore();
// sickly pallor
if (s.sickUntil && G.time.absMin < s.sickUntil) {
ctx.fillStyle = 'rgba(130,200,90,.30)';
ctx.beginPath(); ctx.arc(0, bodyY - 8, 10, 0, 7); ctx.fill();
}
// plumbob for selected
if (s.selected) {
const bobP = Math.sin(R.time * 3) * 3;
const [gx, gy] = [px, py - (opts.heightOffset ?? 62) * zoom * stageK + bobP * zoom];
ctx.fillStyle = s.plumbob();
ctx.beginPath();
ctx.moveTo(gx, gy - 7 * zoom); ctx.lineTo(gx + 5 * zoom, gy);
ctx.lineTo(gx, gy + 7 * zoom); ctx.lineTo(gx - 5 * zoom, gy);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.stroke();
}
// thought bubble
if (s.bubble) {
const bx = px + 16 * zoom * Math.max(stageK, .8), by = py - 74 * zoom * Math.max(stageK, .8);
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.beginPath(); ctx.arc(bx, by, 12 * zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(bx - 10 * zoom, by + 10 * zoom, 3 * zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(bx - 14 * zoom, by + 15 * zoom, 1.6 * zoom, 0, 7); ctx.fill();
ctx.font = `${13 * zoom}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(s.bubble.icon, bx, by + 1);
ctx.textBaseline = 'alphabetic';
}
}
function collectSims(items) {
for (const s of G.sims) {
if (!s.atHome && !s.atWork) continue;
if (!s.atHome) continue;
const depth = s.x + s.y + 0.35;
items.push({ depth, sub: 2, fn: (ctx) => {
const [ax, ay] = isoToScreen(s.x, s.y);
const px = ax * G.cam.zoom + G.cam.x;
const py = ay * G.cam.zoom + G.cam.y;
// selection ring
if (s.selected) {
ctx.strokeStyle = 'rgba(255,210,62,.9)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(px, py, 14 * G.cam.zoom, 7 * G.cam.zoom, 0, 0, 7); ctx.stroke();
}
drawSimSprite(ctx, px, py, s, { animT: s.animT * .12 + R.time, anim: s.anim, facing: s.facing });
}});
}
}
function collectFires(items) {
for (const f of (G.fires || [])) {
items.push({ depth: f.x + f.y + .5, sub: 4, fn: (ctx) => {
const [ax, ay] = isoToScreen(f.x + .5, f.y + .5);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
const flick = Math.sin(R.time * 13 + f.x * 7) * .2 + 1;
ctx.save();
ctx.translate(px, py);
ctx.scale(G.cam.zoom, G.cam.zoom);
// glow
const g = ctx.createRadialGradient(0, -10, 2, 0, -10, 34 * flick);
g.addColorStop(0, 'rgba(255,160,40,.55)');
g.addColorStop(1, 'rgba(255,120,20,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, -10, 34 * flick, 0, 7); ctx.fill();
// flame tongues
for (let i = 0; i < 3; i++) {
const fx = (i - 1) * 7;
const fh = (16 + Math.sin(R.time * 11 + i * 2) * 6) * flick;
ctx.fillStyle = ['#e84a1a', '#ff8c1a', '#ffd23e'][i];
ctx.beginPath();
ctx.moveTo(fx - 6, 2);
ctx.quadraticCurveTo(fx - 8, -fh * .5, fx, -fh);
ctx.quadraticCurveTo(fx + 8, -fh * .5, fx + 6, 2);
ctx.closePath(); ctx.fill();
}
ctx.font = '14px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🔥', 0, -26 - Math.abs(Math.sin(R.time * 6)) * 6);
ctx.restore();
}});
}
}
function collectGhosts(items) {
const h = G.time.hourFloat;
if (!(h >= 1 && h < 4) || !G.ghosts) return;
for (const gh of G.ghosts) {
items.push({ depth: gh.x + gh.y + .4, sub: 3, fn: (ctx) => {
const [ax, ay] = isoToScreen(gh.x, gh.y);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
const bob = Math.sin(R.time * 2 + gh.wobble) * 5;
ctx.save();
ctx.globalAlpha = .45 + Math.sin(R.time * 3 + gh.wobble) * .12;
// translucent shroud
ctx.fillStyle = '#cfe8ff';
ctx.beginPath();
ctx.moveTo(px - 9 * G.cam.zoom, py);
ctx.quadraticCurveTo(px - 10 * G.cam.zoom, py - 30 * G.cam.zoom + bob, px, py - 32 * G.cam.zoom + bob);
ctx.quadraticCurveTo(px + 10 * G.cam.zoom, py - 30 * G.cam.zoom + bob, px + 9 * G.cam.zoom, py);
// wavy tail
ctx.quadraticCurveTo(px + 5 * G.cam.zoom, py - 6 * G.cam.zoom, px, py);
ctx.quadraticCurveTo(px - 5 * G.cam.zoom, py - 6 * G.cam.zoom, px - 9 * G.cam.zoom, py);
ctx.fill();
// face
ctx.globalAlpha = .9;
ctx.fillStyle = '#233021';
ctx.beginPath(); ctx.arc(px - 3 * G.cam.zoom, py - 22 * G.cam.zoom + bob, 1.4 * G.cam.zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(px + 3 * G.cam.zoom, py - 22 * G.cam.zoom + bob, 1.4 * G.cam.zoom, 0, 7); ctx.fill();
ctx.font = `${Math.round(11 * G.cam.zoom)}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('👻', px, py - 40 * G.cam.zoom + bob);
ctx.restore();
}});
}
}
/* fx layer (floating texts etc.) */
const FX = [];
function addFloatText(x, y, text, color = '#fff') {
FX.push({ x, y, text, color, t: 0 });
}
function collectFx(items) {
for (const f of FX) {
items.push({ depth: 9999, sub: 9, fn: (ctx) => {
const [ax, ay] = isoToScreen(f.x, f.y);
const px = ax * G.cam.zoom + G.cam.x;
const py = (ay * G.cam.zoom + G.cam.y) - 30 - f.t * 22;
ctx.globalAlpha = clamp(1 - f.t, 0, 1);
ctx.font = `bold ${13 * G.cam.zoom}px Segoe UI`;
ctx.textAlign = 'center';
ctx.fillStyle = f.color;
ctx.strokeStyle = 'rgba(0,0,0,.6)'; ctx.lineWidth = 3;
ctx.strokeText(f.text, px, py); ctx.fillText(f.text, px, py);
ctx.globalAlpha = 1;
}});
}
}
function tickFx(dt) {
for (const f of FX) f.t += dt;
for (let i = FX.length - 1; i >= 0; i--) if (FX[i].t >= 1.4) FX.splice(i, 1);
}
/* ---------------- ghost previews ---------------- */
function drawGhost(ctx) {
if (G.mode === 'buy' && G.buySel && G.mouseTile) {
const def = OBJECTS[G.buySel];
const rot = G.buyRot;
const w = rot ? def.h : def.w, h = rot ? def.w : def.h;
const tx = G.mouseTile[0], ty = G.mouseTile[1];
const ok = G.funds >= def.price && G.world.canPlace(def, tx, ty, rot);
// footprint cells
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
diamondPath(ctx, tx + dx, ty + dy);
ctx.fillStyle = ok ? 'rgba(80,220,100,.4)' : 'rgba(230,70,70,.4)';
ctx.fill();
ctx.strokeStyle = ok ? '#43e05a' : '#e05a5a';
ctx.stroke();
}
// translucent preview
ctx.globalAlpha = .65;
const fake = { id:-1, defId:G.buySel, x:tx, y:ty, rot, w, h, dirty:0, usedBy:null };
drawObject(ctx, fake, def);
ctx.globalAlpha = 1;
}
if (G.mode === 'build') {
const ht = G.hoverEdge;
if (ht && ['wall','door','window'].includes(G.buildTool)) {
const A = ht.e === 'n' ? tileCornerPx(ht.x, ht.y) : tileCornerPx(ht.x, ht.y);
const B = ht.e === 'n' ? tileCornerPx(ht.x + 1, ht.y) : tileCornerPx(ht.x, ht.y + 1);
const z = WALL_H * G.cam.zoom;
ctx.strokeStyle = G.buildTool === 'wall' ? 'rgba(255,255,255,.9)' : 'rgba(120,220,255,.95)';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(A[0], A[1] - z); ctx.lineTo(B[0], B[1] - z); ctx.stroke();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]); ctx.stroke();
ctx.setLineDash([]);
}
if (G.buildTool === 'floor' && G.mouseTile) {
diamondPath(ctx, G.mouseTile[0], G.mouseTile[1]);
const f = FLOORS[G.floorSel] || FLOORS[0];
ctx.fillStyle = f.c1; ctx.globalAlpha = .7; ctx.fill(); ctx.globalAlpha = 1;
ctx.strokeStyle = '#fff'; ctx.stroke();
}
if (G.buildTool === 'delWall' && G.hoverEdge) {
const A = tileCornerPx(G.hoverEdge.x, G.hoverEdge.y);
const B = G.hoverEdge.e === 'n' ? tileCornerPx(G.hoverEdge.x + 1, G.hoverEdge.y) : tileCornerPx(G.hoverEdge.x, G.hoverEdge.y + 1);
ctx.strokeStyle = 'rgba(255,80,80,.95)'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(A[0], A[1] - WALL_H * G.cam.zoom); ctx.lineTo(B[0], B[1] - WALL_H * G.cam.zoom); ctx.stroke();
}
}
}
/* ---------------- lighting ---------------- */
function getNightness() {
if (!G.time) return 0;
const h = G.time.hourFloat;
if (h >= 21 || h < 5) return 1;
if (h >= 19) return (h - 19) / 2;
if (h < 7) return (7 - h) / 2;
return 0;
}
function mixColor(c1, c2, t) {
const p = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
const a = p(c1), b = p(c2);
return `rgb(${Math.round(lerp(a[0], b[0], t))},${Math.round(lerp(a[1], b[1], t))},${Math.round(lerp(a[2], b[2], t))})`;
}
let lightCv = null;
function drawLighting(ctx) {
const n = getNightness();
if (n <= 0.02 && !(G.time.hourFloat >= 6 && G.time.hourFloat < 8) &&
!(G.time.hourFloat >= 17 && G.time.hourFloat < 19)) return;
if (!lightCv) { lightCv = document.createElement('canvas'); }
if (lightCv.width !== R.W || lightCv.height !== R.H) { lightCv.width = R.W; lightCv.height = R.H; }
const lc = lightCv.getContext('2d');
lc.clearRect(0, 0, R.W, R.H);
// darkness
lc.fillStyle = `rgba(10,14,44,${n * .52})`;
lc.fillRect(0, 0, R.W, R.H);
// dawn/dusk warmth
const h = G.time.hourFloat;
if ((h >= 6 && h < 8) || (h >= 17 && h < 19)) {
const wt = h < 8 ? (8 - h) / 2 : (h - 17) / 2;
lc.fillStyle = `rgba(255,150,60,${wt * .16})`;
lc.fillRect(0, 0, R.W, R.H);
}
// punch lights out
lc.globalCompositeOperation = 'destination-out';
const punch = (wx, wy, r, strength = 1) => {
const [ax, ay] = isoToScreen(wx, wy);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y - 20 * G.cam.zoom;
const rr = r * G.cam.zoom;
const g = lc.createRadialGradient(px, py, rr * .15, px, py, rr);
g.addColorStop(0, `rgba(0,0,0,${strength})`);
g.addColorStop(1, 'rgba(0,0,0,0)');
lc.fillStyle = g;
lc.beginPath(); lc.arc(px, py, rr, 0, 7); lc.fill();
};
for (const o of G.world.objects) {
const def = OBJECTS[o.defId];
if (def.light) punch(o.x + o.w / 2, o.y + o.h / 2, def.light, .9);
if (o.defId === 'tv' && o.usedBy) punch(o.x + 1, o.y + .5, 60, .5);
if (o.defId === 'stove' && o.usedBy) punch(o.x + .5, o.y + .5, 40, .6);
}
for (const f of (G.fires || [])) punch(f.x + .5, f.y + .5, 95, .85);
for (const s of G.sims) if (s.atHome) punch(s.x, s.y, 34, .35);
lc.globalCompositeOperation = 'source-over';
ctx.drawImage(lightCv, 0, 0);
}
/* ============================================================
* Portrait / CAS rendering (standalone canvases)
* ============================================================ */
function drawSimToCanvas(cv, simData, opts = {}) {
const c = cv.getContext('2d');
c.clearRect(0, 0, cv.width, cv.height);
const sc = opts.scale ?? Math.min(cv.width / 90, cv.height / 130);
const px = cv.width / 2 - (opts.offsetX ?? 0) * sc;
const py = cv.height - (opts.groundPad ?? 8);
const fakeSim = typeof simData === 'object' ? simData : { skin:0 };
const savedZoom = G.cam ? G.cam.zoom : 1;
if (opts.standalone !== false) {
// temporarily neutralize camera for plumbob math
}
drawSimSprite(c, px, py, fakeSim, {
zoom: sc, facing: opts.facing ?? 0, anim: opts.anim ?? 'idle',
animT: opts.animT ?? 0, scale: 1, heightOffset: opts.heightOffset ?? 62,
});
return c;
}