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
This commit is contained in:
@@ -0,0 +1,990 @@
|
||||
// ============ 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();
|
||||
}
|
||||
Reference in New Issue
Block a user