// ============ renderer.js — isometric canvas renderer ============ import { TILE_W, TILE_H, Z_STEP } from '../core/config.js'; import { clamp } from '../core/util.js'; import { getState } from '../game/state.js'; import { trainPosition } from '../game/rides.js'; export const TW2 = TILE_W / 2, TH2 = TILE_H / 2; export function makeCamera() { return { x: 26, y: 26, zoom: 1 }; } export function worldToScreen(cam, cw, ch, wx, wy, wz = 0) { const z = cam.zoom; const ux = wx - cam.x, uy = wy - cam.y; // camera-relative return [ (ux - uy) * TW2 * z + cw / 2, (ux + uy) * TH2 * z + ch / 2 - (wz || 0) * Z_STEP * z, ]; } export function screenToWorld(cam, cw, ch, sx, sy) { const z = cam.zoom; const A = (sx - cw / 2) / (TW2 * z); const B = (sy - ch / 2) / (TH2 * z); // ignore height for tile picking const wx = (A + B) / 2 + cam.x; const wy = (B - A) / 2 + cam.y; return [Math.floor(wx), Math.floor(wy)]; } // ---------------- main entry ---------------- export const renderErrors = {}; function guarded(name, fn) { try { fn(); } catch (e) { if (!renderErrors[name]) { renderErrors[name] = e; console.error(`[render:${name}]`, e); } } } export function render(ctx, state, cam, cw, ch, mouse) { ctx.clearRect(0, 0, cw, ch); // sky backdrop gradient const night = nightFactor(state.time.hour); const g = ctx.createLinearGradient(0, 0, 0, ch); if (night > 0.5) { g.addColorStop(0, '#0b1030'); g.addColorStop(1, '#141a38'); } else if (night > 0) { g.addColorStop(0, '#4a5fa8'); g.addColorStop(1, '#8d7fb8'); } else { g.addColorStop(0, '#79b7e8'); g.addColorStop(1, '#a8d8f0'); } ctx.fillStyle = g; ctx.fillRect(0, 0, cw, ch); guarded('terrain', () => drawTerrain(ctx, state, cam, cw, ch)); guarded('objects', () => drawObjects(ctx, state, cam, cw, ch)); guarded('entities', () => drawEntities(ctx, state, cam, cw, ch)); guarded('track', () => drawTrackAll(ctx, state, cam, cw, ch)); guarded('ghosts', () => drawGhosts(ctx, state, cam, cw, ch, mouse)); guarded('effects', () => drawEffects(ctx, state, cam, cw, ch)); guarded('weather', () => drawWeatherFx(ctx, state, cw, ch)); guarded('daynight', () => drawDayNight(ctx, state, cam, cw, ch)); } function nightFactor(hour) { // 0 = full day, 1 = full night if (hour >= 21 || hour < 5) return 1; if (hour >= 19) return (hour - 19) / 2; if (hour < 7) return 1 - (hour - 5) / 2; return 0; } // ---------------- terrain & paths ---------------- const TERRAIN_COLORS = { 0: ['#4d8a3d', '#57a047'], 1: ['#cbb26a', '#d5bd77'], 2: ['#7a7f8a', '#868b96'], 3: ['#2e6db4', '#3a7cc9'] }; function tilePoly(ctx, sx, sy, zoom) { const w = TW2 * zoom, h = TH2 * zoom; ctx.beginPath(); ctx.moveTo(sx, sy - h); ctx.lineTo(sx + w, sy); ctx.lineTo(sx, sy + h); ctx.lineTo(sx - w, sy); ctx.closePath(); } /** World-space bounds of everything visible on screen (all 4 corners -> iso diamond). */ export function visibleBounds(cam, cw, ch, pad = 80) { const z = cam.zoom; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const [sx, sy] of [[-pad, -pad], [cw + pad, -pad], [-pad, ch + pad], [cw + pad, ch + pad]]) { const A = (sx - cw / 2) / (TW2 * z); const B = (sy - ch / 2) / (TH2 * z); const wx = (A + B) / 2 + cam.x, wy = (B - A) / 2 + cam.y; if (wx < minX) minX = wx; if (wx > maxX) maxX = wx; if (wy < minY) minY = wy; if (wy > maxY) maxY = wy; } return { x0: Math.floor(minX) - 1, x1: Math.ceil(maxX) + 1, y0: Math.floor(minY) - 1, y1: Math.ceil(maxY) + 1, }; } function drawTerrain(ctx, state, cam, cw, ch) { const m = state.map, z = cam.zoom; const n = m.size; // visible bounds in world space (transform ALL four screen corners) const vb = visibleBounds(cam, cw, ch, 80); const x0 = clamp(vb.x0, 0, n - 1), x1 = clamp(vb.x1, 0, n - 1); const y0 = clamp(vb.y0, 0, n - 1), y1 = clamp(vb.y1, 0, n - 1); for (let y = y0; y <= y1; y++) { for (let x = x0; x <= x1; x++) { const i = m.idx(x, y); const t = m.terrain[i]; const [c1, c2] = TERRAIN_COLORS[t] || TERRAIN_COLORS[0]; const [sx, sy] = worldToScreen(cam, cw, ch, x + 0.5, y + 0.5); tilePoly(ctx, sx, sy, z); ctx.fillStyle = ((x + y) % 2 === 0) ? c1 : c2; ctx.fill(); // water shimmer if (t === 3 && ((x * 7 + y * 13 + Math.floor(state.time.hour * 30)) % 11 === 0)) { ctx.fillStyle = 'rgba(255,255,255,.18)'; ctx.fillRect(sx - 6 * z, sy - 1 * z, 12 * z, 2 * z); } // path const pt = m.pathType[i]; if (pt) { tilePoly(ctx, sx, sy, z * 0.92); ctx.fillStyle = pt === 1 ? '#b8b2a5' : '#6f6a80'; ctx.fill(); ctx.strokeStyle = pt === 1 ? 'rgba(0,0,0,.15)' : 'rgba(255,255,255,.08)'; ctx.lineWidth = Math.max(1, z); ctx.stroke(); // litter/vomit stains const lit = m.litter[i], vom = m.vomit[i]; if (lit > 0.25 || vom > 0.25) { ctx.fillStyle = vom > 0.25 ? 'rgba(150,190,90,.55)' : 'rgba(90,70,40,.5)'; ctx.beginPath(); ctx.arc(sx + ((x * 13) % 7 - 3) * z, sy + ((y * 17) % 5 - 2) * z, 2.4 * z + lit * 2 * z, 0, Math.PI * 2); ctx.fill(); } } } } // park border walls ctx.strokeStyle = 'rgba(20,24,40,.5)'; ctx.lineWidth = 2 * z; const corners = [[0, 0], [n, 0], [n, n], [0, n]].map(([x, y]) => worldToScreen(cam, cw, ch, x, y)); ctx.beginPath(); corners.forEach(([sx, sy], i) => i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy)); ctx.closePath(); ctx.stroke(); // entrance gate drawEntrance(ctx, state, cam, cw, ch); } function drawEntrance(ctx, state, cam, cw, ch) { const m = state.map; const ex = m.entranceX, ey = m.size - 1; const [sx, sy] = worldToScreen(cam, cw, ch, ex + 0.5, ey + 0.9); const z = cam.zoom; ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); // gate pillars ctx.fillStyle = '#8d887c'; ctx.fillRect(-34, -46, 10, 46); ctx.fillRect(24, -46, 10, 46); ctx.fillStyle = '#f5c542'; ctx.fillRect(-36, -56, 14, 10); ctx.fillRect(22, -56, 14, 10); // arch banner ctx.fillStyle = '#5b3ea8'; ctx.beginPath(); ctx.moveTo(-30, -50); ctx.quadraticCurveTo(0, -78, 30, -50); ctx.lineTo(30, -44); ctx.quadraticCurveTo(0, -70, -30, -44); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = 'bold 7px Trebuchet MS'; ctx.textAlign = 'center'; ctx.fillText('ARCANE PARK', 0, -54); ctx.restore(); } // ---------------- objects (sorted by depth) ---------------- function drawObjects(ctx, state, cam, cw, ch) { const items = []; for (const r of state.rides) items.push({ d: r.x + r.y + r.w + r.h, kind: 'ride', o: r }); for (const s of state.shops) items.push({ d: s.x + s.y + 1, kind: 'shop', o: s }); for (const sc of state.sceneryList) items.push({ d: sc.x + sc.y + sc.def.size, kind: 'scenery', o: sc }); if (state.guild) items.push({ d: state.guild.x + state.guild.y + 3, kind: 'guild', o: state.guild }); items.sort((a, b) => a.d - b.d); for (const it of items) { switch (it.kind) { case 'ride': drawRide(ctx, state, it.o, cam, cw, ch); break; case 'shop': drawShop(ctx, it.o, cam, cw, ch, state); break; case 'scenery': drawScenery(ctx, it.o, cam, cw, ch); break; case 'guild': drawGuild(ctx, it.o, cam, cw, ch); break; } } } function shadow(ctx, sx, sy, rx, ry) { ctx.fillStyle = 'rgba(0,0,0,.22)'; ctx.beginPath(); ctx.ellipse(sx, sy, rx, ry, 0, 0, Math.PI * 2); ctx.fill(); } function drawShop(ctx, s, cam, cw, ch, state) { const [sx, sy] = worldToScreen(cam, cw, ch, s.x + 0.5, s.y + 0.5); const z = cam.zoom; shadow(ctx, sx, sy + 2 * z, 16 * z, 7 * z); ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); // hut body ctx.fillStyle = s.damaged > 0.05 ? '#6b5b4d' : '#a8814f'; ctx.fillRect(-13, -22, 26, 22); // striped awning ctx.fillStyle = '#e05b5b'; for (let i = 0; i < 4; i++) { if (i % 2 === 0) { ctx.fillStyle = '#e05b5b'; } else ctx.fillStyle = '#fff'; ctx.fillRect(-14 + i * 7, -28, 7, 7); } ctx.fillStyle = '#5b3ea8'; ctx.fillRect(-15, -29, 30, 3); // roof peak ctx.fillStyle = '#7c5230'; ctx.beginPath(); ctx.moveTo(-15, -22); ctx.lineTo(0, -34); ctx.lineTo(15, -22); ctx.closePath(); ctx.fill(); // counter glow when open if (!s.damaged) { ctx.fillStyle = '#ffd166'; ctx.font = 'bold 10px serif'; ctx.textAlign = 'center'; ctx.fillText(s.def.icon, 0, -8); } else { ctx.fillStyle = '#ff6b6b'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('✚', 0, -8); } ctx.restore(); } function drawGuild(ctx, gd, cam, cw, ch) { const cx = gd.x + gd.w / 2, cy = gd.y + gd.h / 2; const [sx, sy] = worldToScreen(cam, cw, ch, cx, cy); const z = cam.zoom; shadow(ctx, sx, sy + 4 * z, 30 * z, 12 * z); ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); // stone keep ctx.fillStyle = '#7d8496'; ctx.fillRect(-26, -34, 52, 34); ctx.fillStyle = '#666d7e'; ctx.fillRect(-30, -44, 10, 44); ctx.fillRect(20, -44, 10, 44); ctx.fillStyle = '#4a4f60'; ctx.fillRect(-30, -48, 10, 6); ctx.fillRect(20, -48, 10, 6); // door ctx.fillStyle = '#3d2c1e'; ctx.fillRect(-6, -16, 12, 16); // banners ctx.fillStyle = '#a86bff'; ctx.fillRect(-20, -30, 6, 14); ctx.fillRect(14, -30, 6, 14); // shield emblem ctx.fillStyle = '#f5c542'; ctx.beginPath(); ctx.arc(0, -26, 6, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#5b3ea8'; ctx.font = 'bold 8px serif'; ctx.textAlign = 'center'; ctx.fillText('⚔', 0, -23); // pennant ctx.strokeStyle = '#3d2c1e'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, -34); ctx.lineTo(0, -52); ctx.stroke(); ctx.fillStyle = '#e05b5b'; ctx.beginPath(); ctx.moveTo(0, -52); ctx.lineTo(12 * (0.8 + 0.2 * Math.sin(performance.now() / 300)), -48); ctx.lineTo(0, -44); ctx.closePath(); ctx.fill(); ctx.restore(); } function drawScenery(ctx, sc, cam, cw, ch) { const [sx, sy] = worldToScreen(cam, cw, ch, sc.x + sc.def.size / 2, sc.y + sc.def.size / 2); const z = cam.zoom; const t = sc.type; ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); switch (t) { case 'tree_oak': case 'tree_pine': case 'tree_cherry': { shadow(ctx, 0, 2, 10, 4); ctx.fillStyle = '#6b4a2b'; ctx.fillRect(-2, -10, 4, 10); if (t === 'tree_pine') { ctx.fillStyle = '#2e6b34'; ctx.beginPath(); ctx.moveTo(0, -34); ctx.lineTo(10, -12); ctx.lineTo(-10, -12); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(12, -6); ctx.lineTo(-12, -6); ctx.closePath(); ctx.fill(); } else { const col = t === 'tree_cherry' ? '#f7a8d0' : '#3f7d3a'; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(0, -16, 10, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(-7, -12, 7, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(7, -12, 7, 0, Math.PI * 2); ctx.fill(); } break; } case 'flowerbed': { shadow(ctx, 0, 1, 9, 4); ctx.fillStyle = '#7a5230'; ctx.fillRect(-9, -4, 18, 6); for (let i = 0; i < 4; i++) { ctx.fillStyle = ['#ff6b6b', '#ffd166', '#f7a8ff', '#fff'][i]; ctx.beginPath(); ctx.arc(-6 + i * 4, -5 - (i % 2), 2.2, 0, Math.PI * 2); ctx.fill(); } break; } case 'hedge': { ctx.fillStyle = '#2e6b34'; ctx.beginPath(); ctx.roundRect(-10, -10, 20, 12, 4); ctx.fill(); break; } case 'bench': { shadow(ctx, 0, 1, 9, 3); ctx.fillStyle = '#8d887c'; ctx.fillRect(-9, -3, 18, 3); ctx.fillStyle = '#6b4a2b'; ctx.fillRect(-9, -8, 18, 4); break; } case 'bin': { shadow(ctx, 0, 1, 5, 2.5); ctx.fillStyle = '#3f7d3a'; ctx.fillRect(-4, -10, 8, 10); ctx.fillStyle = '#2e5d2b'; ctx.fillRect(-5, -12, 10, 3); break; } case 'lamp': case 'crystal_lamp': { shadow(ctx, 0, 1, 4, 2); ctx.fillStyle = '#3d4457'; ctx.fillRect(-1.2, -20, 2.4, 20); if (t === 'lamp') { ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.arc(0, -22, 4, 0, Math.PI * 2); ctx.fill(); } else { ctx.fillStyle = '#a86bff'; ctx.beginPath(); ctx.moveTo(0, -28); ctx.lineTo(4.5, -21); ctx.lineTo(0, -14); ctx.lineTo(-4.5, -21); ctx.closePath(); ctx.fill(); } break; } case 'fountain': { shadow(ctx, 0, 2, 13, 6); ctx.fillStyle = '#8d887c'; ctx.beginPath(); ctx.ellipse(0, 0, 12, 6, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#58c1ff'; ctx.beginPath(); ctx.ellipse(0, 0, 9, 4.2, 0, 0, Math.PI * 2); ctx.fill(); const ph = performance.now() / 200 % 1; ctx.fillStyle = 'rgba(180,225,255,.9)'; ctx.fillRect(-1, -14 - ph * 4, 2, 8); ctx.beginPath(); ctx.arc(0, -14 - ph * 4, 2, 0, Math.PI * 2); ctx.fill(); break; } case 'statue_knight': { shadow(ctx, 0, 2, 8, 3.5); ctx.fillStyle = '#9aa4c0'; ctx.fillRect(-3, -16, 6, 16); ctx.beginPath(); ctx.arc(0, -19, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#c0c8dd'; ctx.fillRect(4, -22, 1.6, 12); break; } case 'statue_dragon': { shadow(ctx, 0, 2, 14, 6); ctx.fillStyle = '#5fae6f'; ctx.beginPath(); ctx.ellipse(0, -8, 12, 7, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(9, -14, 4.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#3d7d4d'; ctx.beginPath(); ctx.moveTo(-8, -12); ctx.lineTo(-16, -20); ctx.lineTo(-6, -18); ctx.closePath(); ctx.fill(); break; } case 'ley_pool': { const pulse = 0.85 + 0.15 * Math.sin(performance.now() / 400); ctx.fillStyle = 'rgba(168,107,255,.25)'; ctx.beginPath(); ctx.arc(0, 0, 16 * pulse, 0, Math.PI * 2); ctx.fill(); shadow(ctx, 0, 2, 13, 6); ctx.fillStyle = '#5b3ea8'; ctx.beginPath(); ctx.ellipse(0, 0, 12, 6, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = `rgba(195,155,255,${pulse})`; ctx.beginPath(); ctx.ellipse(0, 0, 8, 3.8, 0, 0, Math.PI * 2); ctx.fill(); // floating orb const oy = -10 - Math.sin(performance.now() / 500) * 3; ctx.fillStyle = '#e0ccff'; ctx.beginPath(); ctx.arc(0, oy, 3, 0, Math.PI * 2); ctx.fill(); break; } case 'rune_stone': { shadow(ctx, 0, 1, 6, 3); ctx.fillStyle = '#565d73'; ctx.fillRect(-4, -16, 8, 16); ctx.fillStyle = '#a86bff'; ctx.font = 'bold 7px serif'; ctx.textAlign = 'center'; ctx.fillText('ᚱ', 0, -9); break; } case 'mushroom_glow': { for (let i = 0; i < 3; i++) { const ox = [-5, 3, 0][i], h2 = [7, 9, 5][i]; ctx.fillStyle = '#cbb2ff'; ctx.beginPath(); ctx.arc(ox, -h2, 4, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#efe6ff'; ctx.fillRect(ox - 1, -h2, 2, h2); } break; } case 'banner': { ctx.fillStyle = '#3d2c1e'; ctx.fillRect(-1, -22, 2, 22); const sway = Math.sin(performance.now() / 350) * 2; ctx.fillStyle = '#f5c542'; ctx.beginPath(); ctx.moveTo(1, -22); ctx.lineTo(11 + sway, -19); ctx.lineTo(1, -14); ctx.closePath(); ctx.fill(); break; } default: ctx.font = '14px serif'; ctx.textAlign = 'center'; ctx.fillText(sc.def.icon || '❓', 0, -8); } ctx.restore(); } // ---------------- rides ---------------- function drawRide(ctx, state, r, cam, cw, ch) { const cx = r.x + r.w / 2, cy = r.y + r.h / 2; const [sx, sy] = worldToScreen(cam, cw, ch, cx, cy); const z = cam.zoom; const t = performance.now() / 1000; const running = r.status === 'open' || r.status === 'testing'; const ph = r.animPhase; shadow(ctx, sx, sy + 3 * z, 22 * r.w / 2 * z / 1, 9 * z); ctx.save(); ctx.translate(sx, sy); ctx.scale(z, z); switch (r.type) { case 'carousel': { // canopy ctx.fillStyle = r.def.color2; ctx.beginPath(); ctx.moveTo(-24, -26); ctx.lineTo(0, -38); ctx.lineTo(24, -26); ctx.closePath(); ctx.fill(); ctx.fillStyle = r.def.color; for (let i = 0; i < 6; i++) { const a = running ? ph * 1.2 + i * Math.PI / 3 : i * Math.PI / 3; const ox = Math.cos(a) * 14, oy = Math.sin(a) * 6; const bounce = Math.abs(Math.sin(ph * 2 + i)) * 3; ctx.strokeStyle = '#8d887c'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ox * 0.4, -26); ctx.lineTo(ox, oy - 10 + bounce); ctx.stroke(); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(ox, oy - 13 + bounce, 4, 0, Math.PI * 2); ctx.fill(); // unicorn head-ish ctx.fillStyle = r.def.color; ctx.fillRect(ox - 3, oy - 10 + bounce, 6, 8); } ctx.fillStyle = '#8d6aa8'; ctx.beginPath(); ctx.ellipse(0, -2, 18, 8, 0, 0, Math.PI * 2); ctx.fill(); break; } case 'ferris': { const R = 26; ctx.strokeStyle = '#5b6274'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(0, -R - 6); ctx.lineTo(14, 0); ctx.stroke(); const rot = running ? ph * 0.35 : 0; ctx.strokeStyle = r.def.color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, -R - 6, R, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < 8; i++) { const a = rot + i * Math.PI / 4; const gx = Math.cos(a) * R, gy = -R - 6 + Math.sin(a) * R * 0.82; ctx.strokeStyle = 'rgba(127,178,255,.6)'; ctx.beginPath(); ctx.moveTo(0, -R - 6); ctx.lineTo(gx, gy); ctx.stroke(); ctx.fillStyle = i % 2 ? r.def.color2 : '#fff'; ctx.fillRect(gx - 3, gy - 3, 6, 6); } break; } case 'drop_tower': { ctx.fillStyle = '#5b6274'; ctx.fillRect(-5, -52, 10, 52); const cyc = running ? (ph % 4) : 0; let carY = -8; if (running) { if (cyc < 2.2) carY = -8 - (cyc / 2.2) * 40; // rise else if (cyc < 2.45) carY = -48; // top hang else carY = -48 + ((cyc - 2.45) / 0.35) ** 2 * 40; // drop! } ctx.fillStyle = r.def.color2; ctx.fillRect(-8, carY - 4, 16, 6); ctx.fillStyle = '#ffd166'; for (let i = 0; i < 4; i++) ctx.fillRect(-7 + i * 4, carY - 2, 2.5, 2.5); break; } case 'teacups': { ctx.fillStyle = '#c9c2b2'; ctx.beginPath(); ctx.ellipse(0, 0, 18, 8, 0, 0, Math.PI * 2); ctx.fill(); for (let i = 0; i < 5; i++) { const a = running ? ph * 2 + i * Math.PI * 2 / 5 : i * Math.PI * 2 / 5; const ox = Math.cos(a) * 11, oy = Math.sin(a) * 4.5; ctx.fillStyle = i % 2 ? r.def.color : r.def.color2; ctx.beginPath(); ctx.ellipse(ox, oy - 5, 5.5, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(ox + ox * 0.15, oy - 8, 1.6, 0, Math.PI * 2); ctx.fill(); } break; } case 'swings': { ctx.fillStyle = '#5b6274'; ctx.beginPath(); ctx.moveTo(-16, 0); ctx.lineTo(0, -34); ctx.lineTo(16, 0); ctx.stroke ? null : null; ctx.beginPath(); ctx.moveTo(-16, 0); ctx.lineTo(0, -34); ctx.lineTo(16, 0); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(-16, 0); ctx.lineTo(-16, -36); ctx.lineTo(16, -36); ctx.lineTo(16, 0); ctx.closePath(); ctx.fill(); const rot = running ? ph * 1.6 : 0; for (let i = 0; i < 8; i++) { const a = rot + i * Math.PI / 4; const ang = Math.cos(a) * 0.9; const hx = Math.cos(a) * 13, hyv = -36 + Math.sin(a) * 4; const fx = hx + Math.sin(ang) * 14, fy = hyv + Math.cos(Math.abs(ang)) * 12; ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(hx, hyv); ctx.lineTo(fx, fy); ctx.stroke(); ctx.fillStyle = i % 2 ? '#f7a8ff' : '#a5e6ff'; ctx.fillRect(fx - 2.5, fy - 2, 5, 5); } break; } case 'haunted': { ctx.fillStyle = '#4a3d68'; ctx.fillRect(-22, -26, 44, 26); ctx.fillStyle = '#372d52'; ctx.beginPath(); ctx.moveTo(-26, -26); ctx.lineTo(0, -42); ctx.lineTo(26, -26); ctx.closePath(); ctx.fill(); // windows flicker for (let i = 0; i < 3; i++) { const lit = Math.sin(t * 3 + i * 2) > 0; ctx.fillStyle = lit ? '#5de0c8' : '#241d3a'; ctx.fillRect(-14 + i * 11, -20, 6, 8); } if (running && Math.sin(ph * 3) > 0.93) { ctx.globalAlpha = 0.8; ctx.font = '12px serif'; ctx.textAlign = 'center'; ctx.fillText('👻', 6, -34); ctx.globalAlpha = 1; } break; } case 'logflume': { // water channel ring around a hill with drop ctx.fillStyle = '#3a7cc9'; ctx.fillRect(-30, -6, 60, 10); ctx.fillStyle = '#63c5ea'; ctx.fillRect(-28, -5, 56, 6); ctx.fillStyle = '#8d887c'; ctx.beginPath(); ctx.moveTo(-12, -6); ctx.lineTo(0, -30); ctx.lineTo(12, -6); ctx.closePath(); ctx.fill(); // logs animating around if (running) { for (let i = 0; i < 3; i++) { const p2 = ((ph * 0.25 + i / 3) % 1); const lx = -28 + p2 * 56; const liftY = p2 < 0.15 ? -(p2 / 0.15) * 22 : p2 > 0.8 ? -((1 - p2) / 0.2) * 22 : 0; const ly = p2 < 0.15 ? -6 - (p2 / 0.15) * 22 : p2 > 0.75 ? -6 - ((1 - p2) / 0.25) * 24 : -4; ctx.fillStyle = r.def.color2; ctx.fillRect(clamp(lx, -28, 22), ly - 4, 6, 5); } } break; } case 'dragon_coaster': { if (r.isCustomCoaster) break; // real track is drawn by drawTrackAll // prebuilt junior coaster: static hills + dragon train circling ctx.strokeStyle = '#8d887c'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(-26, -2); ctx.quadraticCurveTo(-14, -26, 0, -8); ctx.quadraticCurveTo(14, -30, 26, -2); ctx.stroke(); if (running) { const p2 = ph % 2 / 2; const tx2 = -26 + p2 * 52; const ty2 = -Math.sin(p2 * Math.PI * 2) * 16 - 8; ctx.font = '11px serif'; ctx.textAlign = 'center'; ctx.fillText('🐉', tx2, ty2); ctx.fillText('🐉', tx2 - 8, ty2 + 2); } break; } case 'portal': { const spin = running ? ph * 2 : 0.4; for (let i = 0; i < 3; i++) { ctx.strokeStyle = `rgba(${i === 0 ? '168,107,255' : i === 1 ? '88,193,255' : '245,197,66'},${0.9 - i * 0.25})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.ellipse(0, -14, 16 - i * 3, 20 - i * 4, spin + i, 0, Math.PI * 2); ctx.stroke(); } if (running && Math.random() < 0.3) { ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc((Math.random() - 0.5) * 20, -14 + (Math.random() - 0.5) * 26, 1.4, 0, Math.PI * 2); ctx.fill(); } break; } case 'broom_tower': { ctx.fillStyle = '#7c5230'; ctx.fillRect(-7, -46, 14, 46); ctx.fillStyle = '#5b3ea8'; ctx.fillRect(-9, -50, 18, 6); if (running) { const a = ph * 2; const bx = Math.cos(a) * 14, by = -10 + Math.sin(a * 2) * 4 - ((ph * 6) % 36); ctx.font = '10px serif'; ctx.textAlign = 'center'; ctx.fillText('🧹', bx, by); } break; } default: { ctx.fillStyle = r.def.color; ctx.fillRect(-16, -20, 32, 20); ctx.font = '14px serif'; ctx.textAlign = 'center'; ctx.fillText(r.def.icon, 0, -6); } } // status lamp const statusCol = r.status === 'open' ? '#57d97a' : r.status === 'broken' ? '#ff6b6b' : r.status === 'testing' ? '#ffb347' : '#9aa4c0'; ctx.fillStyle = statusCol; ctx.beginPath(); ctx.arc(r.w > 2 ? 20 : 14, -r.w * 6 - 8, 3, 0, Math.PI * 2); ctx.fill(); // name plate if (cam.zoom > 0.75) { ctx.font = 'bold 8px Trebuchet MS'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(10,12,24,.65)'; const tw = ctx.measureText(r.name).width + 8; ctx.fillRect(-tw / 2, -r.w * 6 - 24, tw, 11); ctx.fillStyle = '#f5c542'; ctx.fillText(r.name, 0, -r.w * 6 - 16); } ctx.restore(); } // ---------------- coaster tracks (custom) ---------------- function trackScreenPos(cam, cw, ch, p) { return worldToScreen(cam, cw, ch, p.x + 0.5, p.y + 0.5, p.z * 2); // z units doubled for drama } function drawTrackAll(ctx, state, cam, cw, ch) { for (const r of state.rides) { if (!r.isCustomCoaster || !r.track?.length) continue; drawCoasterTrack(ctx, r, cam, cw, ch); // train const pos = trainPosition(r); if (pos && (r.riders.length || r.status === 'testing' || r.cycleT > 0)) { const [tx, ty] = worldToScreen(cam, cw, ch, pos.x + 0.5, pos.y + 0.5, pos.z * 2); ctx.save(); ctx.translate(tx, ty); ctx.scale(cam.zoom, cam.zoom); // little cars ctx.fillStyle = '#333'; ctx.fillRect(-6, -3, 12, 5); ctx.fillStyle = r.train?.color || '#e05b5b'; ctx.fillRect(-5, -6, 10, 4); if (pos.loop) { ctx.font = '10px serif'; ctx.textAlign = 'center'; ctx.fillText('🎢', 0, -10); } ctx.restore(); } } // active build session ghost track const sess = state._coasterBuild; if (sess) drawBuildSession(ctx, state, sess, cam, cw, ch); } function drawCoasterTrack(ctx, r, cam, cw, ch) { const tr = r.track; ctx.lineCap = 'round'; for (let i = 0; i < tr.length; i++) { const p = tr[i]; const nx = tr[(i + 1) % tr.length]; const [ax, ay] = trackScreenPos(cam, cw, ch, p); const [bx, by] = trackScreenPos(cam, cw, ch, nx); // supports if (p.z > 0 && p.type !== 'station') { const [gx, gy] = worldToScreen(cam, cw, ch, p.x + 0.5, p.y + 0.5, 0); ctx.strokeStyle = 'rgba(70,74,92,.75)'; ctx.lineWidth = Math.max(1, 2 * cam.zoom); ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(gx, gy); ctx.stroke(); } // ties/base ctx.strokeStyle = '#4a4257'; ctx.lineWidth = Math.max(2, 5 * cam.zoom); ctx.beginPath(); ctx.moveTo(ax, ay); if (p.turn !== 0 || nx.dir !== undefined) { // curved piece: control point at shared corner const mid = { x: p.x + 0.5 + DIRV[p.dir][0] * 0.5, y: p.y + 0.5 + DIRV[p.dir][1] * 0.5 }; const [mx, my] = trackScreenPos(cam, cw, ch, { ...mid, z: (p.z + nx.z) / 2 }); ctx.quadraticCurveTo(mx, my, bx, by); } else { ctx.lineTo(bx, by); } ctx.stroke(); // rails highlight ctx.strokeStyle = p.lift ? '#ffd166' : (p.type === 'loop' ? '#a86bff' : '#e8ecf7'); ctx.lineWidth = Math.max(1, 1.6 * cam.zoom); ctx.stroke(); // loop decoration if (p.type === 'loop') { ctx.strokeStyle = 'rgba(168,107,255,.8)'; ctx.lineWidth = Math.max(1, 2 * cam.zoom); ctx.beginPath(); ctx.ellipse(ax, ay - 10 * cam.zoom, 5 * cam.zoom, 12 * cam.zoom, 0, 0, Math.PI * 2); ctx.stroke(); } if (p.type === 'station') { ctx.fillStyle = '#6b7288'; ctx.fillRect(ax - 8 * cam.zoom, ay - 4 * cam.zoom, 16 * cam.zoom, 6 * cam.zoom); } } } const DIRV = [[1, 0], [0, 1], [-1, 0], [0, -1]]; function drawBuildSession(ctx, state, sess, cam, cw, ch) { // existing pieces solid const fakeRide = { track: sess.pieces.map(p => ({ ...p, dir: p.dir ?? 0 })), train: null }; ctx.globalAlpha = 0.95; drawCoasterTrack(ctx, fakeRide, cam, cw, ch); ctx.globalAlpha = 1; // cursor marker const [cx, cy2] = worldToScreen(cam, cw, ch, sess.cx + 0.5, sess.cy + 0.5, sess.cz * 2); ctx.strokeStyle = '#f5c542'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy2 - 8 * cam.zoom); ctx.lineTo(cx + 6 * cam.zoom, cy2); ctx.lineTo(cx, cy2 + 8 * cam.zoom); ctx.lineTo(cx - 6 * cam.zoom, cy2); ctx.closePath(); ctx.stroke(); } // ---------------- entities ---------------- function drawEntities(ctx, state, cam, cw, ch) { const ents = []; for (const g of state.guests) ents.push({ d: g.x + g.y, e: g, k: 'g' }); for (const s of state.staff) ents.push({ d: s.x + s.y, e: s, k: 's' }); for (const h of state.heroes) if (h.alive) ents.push({ d: h.x + h.y, e: h, k: 'h' }); for (const mo of state.monsters) ents.push({ d: mo.x + mo.y, e: mo, k: 'm' }); ents.sort((a, b) => a.d - b.d); const z = cam.zoom; for (const { e, k } of ents) { const [sx, sy] = worldToScreen(cam, cw, ch, e.x, e.y); if (sx < -30 || sy < -40 || sx > cw + 30 || sy > ch + 40) continue; if (k === 'g') { // guest: tiny person shadow(ctx, sx, sy, 3.4 * z, 1.5 * z); ctx.fillStyle = e.color; ctx.fillRect(sx - 2.4 * z, sy - 8.5 * z, 4.8 * z, 6 * z); ctx.fillStyle = '#ffe0c0'; ctx.beginPath(); ctx.arc(sx, sy - 10.5 * z, 2.6 * z, 0, Math.PI * 2); ctx.fill(); // balloon! if (e.favRide === 'balloon' && false) { } if (z > 0.8 && e.bubbleT > 0 && e.bubble) { drawBubble(ctx, sx, sy - 20 * z, e.bubble, z); } } else if (k === 's') { shadow(ctx, sx, sy, 3.4 * z, 1.5 * z); ctx.fillStyle = { handyman: '#57d97a', mechanic: '#ffb347', guard: '#58c1ff', entertainer: '#f7a8ff' }[e.type] || '#fff'; ctx.fillRect(sx - 2.6 * z, sy - 9 * z, 5.2 * z, 6.4 * z); ctx.fillStyle = '#ffe0c0'; ctx.beginPath(); ctx.arc(sx, sy - 11 * z, 2.7 * z, 0, Math.PI * 2); ctx.fill(); if (z > 1.1) { ctx.font = `${7 * z}px serif`; ctx.textAlign = 'center'; ctx.fillText(e.def.icon, sx, sy - 14 * z); } } else if (k === 'h') { shadow(ctx, sx, sy, 4.5 * z, 2 * z); ctx.fillStyle = '#2a3559'; ctx.fillRect(sx - 3 * z, sy - 10 * z, 6 * z, 7.5 * z); ctx.fillStyle = '#ffe0c0'; ctx.beginPath(); ctx.arc(sx, sy - 12.5 * z, 3 * z, 0, Math.PI * 2); ctx.fill(); ctx.font = `${9 * z}px serif`; ctx.textAlign = 'center'; ctx.fillText(e.def.icon, sx, sy - 16 * z); // hp bar const hpF = e.hp / e.maxHp; ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(sx - 6 * z, sy + 2 * z, 12 * z, 2 * z); ctx.fillStyle = hpF > 0.5 ? '#57d97a' : hpF > 0.25 ? '#ffb347' : '#ff6b6b'; ctx.fillRect(sx - 6 * z, sy + 2 * z, 12 * z * hpF, 2 * z); } else { shadow(ctx, sx, sy, 5 * z, 2.2 * z); ctx.save(); if (e.flashT > 0) { ctx.shadowColor = '#ff5c5c'; ctx.shadowBlur = 10; } ctx.font = `${13 * z}px serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(e.def.icon, sx, sy - 6 * z); ctx.restore(); const hpF = e.hp / e.maxHp; ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(sx - 7 * z, sy + 2 * z, 14 * z, 2.4 * z); ctx.fillStyle = '#ff6b6b'; ctx.fillRect(sx - 7 * z, sy + 2 * z, 14 * z * hpF, 2.4 * z); } } } function drawBubble(ctx, sx, sy, text, z) { ctx.font = `${Math.max(8, 8 * z)}px Trebuchet MS`; const w = ctx.measureText(text).width + 10; const h = 14 * z + 4; ctx.fillStyle = 'rgba(255,255,255,.94)'; ctx.beginPath(); ctx.roundRect(sx - w / 2, sy - h / 2 - h, w, h, 4); ctx.fill(); ctx.beginPath(); ctx.moveTo(sx - 3, sy - h + 2); ctx.lineTo(sx + 3, sy - h + 2); ctx.lineTo(sx, sy - h + 7); ctx.fill(); ctx.fillStyle = '#222'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text.length > 26 ? text.slice(0, 24) + '…' : text, sx, sy - h / 2 - h / 2); ctx.textBaseline = 'alphabetic'; } // ---------------- ghosts & tool overlays ---------------- import { sessionActive, nextCellFor, getSession } from '../game/coaster.js'; function drawGhosts(ctx, state, cam, cw, ch, mouse) { const tool = state._ui?.tool; if (!mouse || !mouse.tile) return; const [hx, hy] = mouse.tile; const m = state.map; if (sessionActive(state)) { const sel = state._ui?.coasterPiece || 'straight'; const sess = getSession(state); const nc = nextCellFor(sess, sel); const [gx, gy] = worldToScreen(cam, cw, ch, nc.x + 0.5, nc.y + 0.5, nc.z * 2); const valid = validateGhost(state, sess, sel); ctx.strokeStyle = valid ? '#57d97a' : '#ff6b6b'; ctx.fillStyle = valid ? 'rgba(87,217,122,.25)' : 'rgba(255,107,107,.25)'; ctx.lineWidth = 2; tilePoly(ctx, gx, gy, cam.zoom * 0.96); ctx.fill(); ctx.stroke(); } else if (tool === 'path' || tool === 'terrain' || (tool === 'scenery' && state._ui?.sel?.size === 1) || tool === 'select') { const [gx, gy] = worldToScreen(cam, cw, ch, hx + 0.5, hy + 0.5); ctx.strokeStyle = 'rgba(245,197,66,.9)'; ctx.lineWidth = Math.max(1.2, cam.zoom); tilePoly(ctx, gx, gy, cam.zoom * 0.98); ctx.stroke(); } else if ((tool === 'ride' || tool === 'shop' || tool === 'scenery') && state._ui?.sel) { const def = state._ui.sel; const w = def.w || def.size || 1, h = def.h || def.size || 1; const ok = canGhostPlace(state, hx, hy, w, h); for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { const [gx, gy] = worldToScreen(cam, cw, ch, hx + xx + 0.5, hy + yy + 0.5); ctx.fillStyle = ok ? 'rgba(87,217,122,.3)' : 'rgba(255,107,107,.3)'; tilePoly(ctx, gx, gy, cam.zoom * 0.96); ctx.fill(); ctx.strokeStyle = ok ? '#57d97a' : '#ff6b6b'; ctx.lineWidth = 1.5; ctx.stroke(); } } else if (tool === 'heroes' && state._ui?.heroSubtool === 'guild') { const ok = guildGhostOk(state, hx, hy); for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) { const [gx, gy] = worldToScreen(cam, cw, ch, hx + xx + 0.5, hy + yy + 0.5); ctx.fillStyle = ok ? 'rgba(168,107,255,.3)' : 'rgba(255,107,107,.3)'; tilePoly(ctx, gx, gy, cam.zoom * 0.96); ctx.fill(); ctx.strokeStyle = ok ? '#a86bff' : '#ff6b6b'; ctx.lineWidth = 1.5; ctx.stroke(); } } } import { validatePiece } from '../game/coaster.js'; function validateGhost(state, sess, sel) { return validatePiece(state, sess, sel).ok; } function canGhostPlace(state, x, y, w, h) { const m = state.map; for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { if (!m.isBuildable(x + xx, y + yy) || m.occupied(x + xx, y + yy)) return false; } return true; } function guildGhostOk(state, x, y) { const m = state.map; for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) { if (!m.isBuildable(x + xx, y + yy) || m.occupied(x + xx, y + yy)) return false; } return true; } // ---------------- effects ---------------- function drawEffects(ctx, state, cam, cw, ch) { const z = cam.zoom; // float texts for (let i = state.floatTexts.length - 1; i >= 0; i--) { const f = state.floatTexts[i]; f.t += 1 / 60; if (f.t >= f.dur) { state.floatTexts.splice(i, 1); continue; } const [sx, sy] = worldToScreen(cam, cw, ch, f.x, f.y); const a = 1 - f.t / f.dur; ctx.globalAlpha = a; ctx.font = `bold ${10 * z}px Trebuchet MS`; ctx.textAlign = 'center'; ctx.fillStyle = '#000'; ctx.fillText(f.text, sx + 1, sy - 18 * z - f.t * 18 + 1); ctx.fillStyle = f.color || '#fff'; ctx.fillText(f.text, sx, sy - 18 * z - f.t * 18); ctx.globalAlpha = 1; } // battle/spell effects for (let i = state.effects.length - 1; i >= 0; i--) { const ef = state.effects[i]; ef.t += 1 / 60; if (ef.t >= ef.dur) { state.effects.splice(i, 1); continue; } const p = ef.t / ef.dur; if (ef.kind === 'hit') { const [sx, sy] = worldToScreen(cam, cw, ch, ef.x, ef.y); ctx.strokeStyle = ef.color || '#ffd166'; ctx.lineWidth = 2 * z; ctx.globalAlpha = 1 - p; ctx.beginPath(); ctx.arc(sx, sy - 6 * z, (4 + p * 12) * z, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1; } else if (ef.kind === 'poof') { const [sx, sy] = worldToScreen(cam, cw, ch, ef.x, ef.y); ctx.globalAlpha = 1 - p; ctx.font = `${14 * z}px serif`; ctx.textAlign = 'center'; ctx.fillText(ef.icon, sx, sy - 8 * z - p * 14); ctx.globalAlpha = 1; } else if (ef.kind === 'heal') { const [sx, sy] = worldToScreen(cam, cw, ch, ef.x, ef.y); ctx.globalAlpha = 1 - p; ctx.fillStyle = '#57d97a'; ctx.font = `bold ${9 * z}px serif`; ctx.textAlign = 'center'; ctx.fillText('✚', sx, sy - 12 * z - p * 12); ctx.globalAlpha = 1; } else if (ef.kind === 'spellburst') { ctx.globalAlpha = (1 - p) * 0.9; ctx.font = `${64 * (0.5 + p)}px serif`; ctx.textAlign = 'center'; ctx.fillText(ef.icon, cw / 2, ch / 2 - 40); ctx.globalAlpha = 1; } } } // ---------------- weather & night overlays ---------------- let raindrops = []; for (let i = 0; i < 160; i++) raindrops.push({ x: Math.random(), y: Math.random(), s: 0.5 + Math.random() }); function drawWeatherFx(ctx, state, cw, ch) { const w = state.weather.cur; if (w === 'rain' || w === 'storm') { const count = w === 'storm' ? 160 : 90; ctx.strokeStyle = 'rgba(160,190,255,.4)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < count; i++) { const d = raindrops[i]; d.y += 0.02 * d.s; d.x += 0.004 * d.s; if (d.y > 1) { d.y = -0.05; d.x = Math.random(); } const rx = d.x * cw, ry = d.y * ch; ctx.moveTo(rx, ry); ctx.lineTo(rx - 3, ry + 9 * d.s); } ctx.stroke(); if (w === 'storm' && Math.random() < 0.006) { ctx.fillStyle = 'rgba(255,255,220,.55)'; ctx.fillRect(0, 0, cw, ch); } } } function drawDayNight(ctx, state, cam, cw, ch) { const n = nightFactor(state.time.hour); if (n > 0) { ctx.fillStyle = `rgba(8,10,34,${n * 0.42})`; ctx.fillRect(0, 0, cw, ch); // lamp glows ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const sc of state.sceneryList) { if (!sc.def.light) continue; const [sx, sy] = worldToScreen(cam, cw, ch, sc.x + 0.5, sc.y + 0.5, 1); const rad = sc.def.light * 10 * cam.zoom; const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, rad); const col = sc.type === 'crystal_lamp' || sc.def.magic ? '168,107,255' : '255,209,102'; grad.addColorStop(0, `rgba(${col},${0.35 * n})`); grad.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(sx, sy, rad, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } const wt = { sunny: null, cloudy: 'rgba(120,130,160,.08)', rain: 'rgba(60,80,140,.15)', storm: 'rgba(30,40,90,.25)' }[state.weather.cur]; if (wt) { ctx.fillStyle = wt; ctx.fillRect(0, 0, cw, ch); } } // ---------------- minimap ---------------- export function renderMinimap(mmCtx, state, cam, cw, ch) { const n = state.map.size; const S = mmCtx.canvas.width / n; const img = mmCtx.createImageData(mmCtx.canvas.width, mmCtx.canvas.height); // simpler: clear and paint rects mmCtx.clearRect(0, 0, mmCtx.canvas.width, mmCtx.canvas.height); for (let y = 0; y < n; y++) { for (let x = 0; x < n; x++) { const i = state.map.idx(x, y); let c; if (state.map.pathType[i]) c = '#b8b2a5'; else c = (TERRAIN_COLORS[state.map.terrain[i]] || TERRAIN_COLORS[0])[0]; mmCtx.fillStyle = c; mmCtx.fillRect(x * S, y * S, S + 0.5, S + 0.5); } } for (const sc of state.sceneryList) { mmCtx.fillStyle = '#2e6b34'; mmCtx.fillRect(sc.x * S, sc.y * S, S, S); } for (const r of state.rides) { mmCtx.fillStyle = '#e05b5b'; mmCtx.fillRect(r.x * S, r.y * S, r.w * S, r.h * S); } for (const s of state.shops) { mmCtx.fillStyle = '#ffd166'; mmCtx.fillRect(s.x * S, s.y * S, S, S); } if (state.guild) { mmCtx.fillStyle = '#a86bff'; mmCtx.fillRect(state.guild.x * S, state.guild.y * S, 2 * S, 2 * S); } for (const g of state.guests) { mmCtx.fillStyle = '#fff'; mmCtx.fillRect(g.x * S - 1, g.y * S - 1, 2, 2); } for (const mo of state.monsters) { mmCtx.fillStyle = '#ff3b3b'; mmCtx.fillRect(mo.x * S - 2, mo.y * S - 2, 4, 4); } for (const h of state.heroes) { if (h.alive) { mmCtx.fillStyle = '#58c1ff'; mmCtx.fillRect(h.x * S - 2, h.y * S - 2, 4, 4); } } // camera viewport indicator (diamond) mmCtx.strokeStyle = 'rgba(255,255,255,.9)'; mmCtx.lineWidth = 1.5; const half = (cw / (TW2 * cam.zoom)) / 2; const cx = cam.x, cy = cam.y; mmCtx.beginPath(); mmCtx.moveTo((cx) * S, (cy - half) * S); mmCtx.lineTo((cx + half) * S, (cy) * S); mmCtx.lineTo((cx) * S, (cy + half) * S); mmCtx.lineTo((cx - half) * S, (cy) * S); mmCtx.closePath(); mmCtx.stroke(); }