/* ============================================================ * 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; }