Files
arcane-tycoon/js/render/renderer.js
T
deepseek ac00687480 Arcane Tycoon — Heroes & Magic theme park tycoon game
Complete browser game inspired by OpenRCT2 with fantasy twist:
- Custom roller coaster designer with physics-based ratings + on-ride POV
- 10 animated rides, 7 shops, 16 scenery items, path network & guest AI
- Heroes guild vs monster invasions (5 classes, XP/gear/bosses)
- Magic spell system (8 spells), research tree, economy/marketing/loans
- Day-night cycle, weather, park rating, awards, 4 scenarios
- Save/load slots + autosave, procedural WebAudio SFX/music
- Isometric canvas renderer, minimap, diagnostics overlay
- Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
2026-08-23 06:59:21 +00:00

991 lines
39 KiB
JavaScript

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