998 lines
40 KiB
JavaScript
998 lines
40 KiB
JavaScript
/* ============================================================
|
||
* 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;
|
||
}
|