Files
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

182 lines
6.1 KiB
JavaScript

// ============ povui.js — on-ride first-person camera ============
import { sampleTrack } from '../game/coaster.js';
import { sfx } from '../core/audio.js';
let povRAF = null;
export function stopPOV() {
const ov = document.getElementById('pov-overlay');
if (ov) ov.remove();
if (povRAF) { cancelAnimationFrame(povRAF); povRAF = null; }
}
export function startPOV(ride) {
if (!ride?.track?.length && !ride.def) return;
// custom coasters use track; prebuilt rides get a synthesized scenic track
let track = ride.track;
if (!track || !track.length) {
track = synthTrackFor(ride);
ride.cycleDur = ride.def.rideTime;
}
stopPOV();
sfx.whoosh();
const ov = document.createElement('div');
ov.id = 'pov-overlay';
ov.innerHTML = `
<canvas id="pov-canvas"></canvas>
<div id="pov-hud">
<span>🎢 <b>${ride.name}</b></span>
<span>💨 <span id="pov-speed">0</span> km/h</span>
<span>⚡ ${ride.excite.toFixed ? ride.excite.toFixed(1) : ride.excite}</span>
<span id="pov-time"></span>
</div>
<button id="pov-exit" class="btn danger">✕ Exit Ride</button>`;
document.body.appendChild(ov);
const cvs = document.getElementById('pov-canvas');
const ctx = cvs.getContext('2d');
document.getElementById('pov-exit').addEventListener('click', stopPOV);
let prog = 0, lastT = performance.now();
const dur = Math.max(8, ride.cycleDur);
const maxSpeed = ride.stats?.maxSpeed || Math.round((ride.def?.excite || 4) * 12);
const isNight = () => { const st = window.__getState?.(); return st ? (st.time.hour >= 20 || st.time.hour < 6) : false; };
function resize() { cvs.width = innerWidth; cvs.height = innerHeight; }
resize();
window.addEventListener('resize', resize);
function frame(now) {
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
prog += dt / dur;
const s = sampleTrack(track, prog % 0.9999);
drawFrame(ctx, cvs.width, cvs.height, s, prog, maxSpeed, isNight(), track, prog);
const sp = document.getElementById('pov-speed');
if (sp) sp.textContent = Math.round(maxSpeed * (0.55 + Math.abs(s.slope ?? 0) * 0.18));
const tm = document.getElementById('pov-time');
if (tm) tm.textContent = `⏱ ${Math.round((prog % 1) * 100)}%`;
if (!document.getElementById('pov-overlay')) return; // exited
povRAF = requestAnimationFrame(frame);
}
povRAF = requestAnimationFrame(frame);
}
function synthTrackFor(ride) {
// simple oval circuit with a hill
const pts = [];
const w = ride.w || 3, h = ride.h || 3;
const cx = ride.x + w / 2, cy = ride.y + h / 2;
const N = 28;
for (let i = 0; i < N; i++) {
const a = i / N * Math.PI * 2;
pts.push({
x: cx + Math.cos(a) * (w / 2 + 1.5),
y: cy + Math.sin(a) * (h / 2 + 1.5),
z: Math.round(Math.abs(Math.sin(a * 2)) * 3),
type: 'straight', dir: Math.floor(a / (Math.PI / 2)) % 4,
lift: false,
});
}
return pts;
}
function drawFrame(ctx, W, H, s, prog, maxSpeed, night, track, rawProg) {
// sky
const g = ctx.createLinearGradient(0, 0, 0, H);
if (night) { g.addColorStop(0, '#0a0e2a'); g.addColorStop(1, '#232a55'); }
else { g.addColorStop(0, '#69aef0'); g.addColorStop(1, '#cfe6f7'); }
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// loop rotation flips world
const loopRot = s.loop ? (prog % 0.9999 > 0.45 && prog % 0.9999 < 0.62 ? Math.PI : 0) : 0;
const slopeTilt = clampN((s.slope ?? 0) * -26, -160, 160);
const steerShift = Math.sin(rawProg * Math.PI * 8) * 40;
ctx.save();
ctx.translate(W / 2 + steerShift, H / 2 + slopeTilt * 0.4);
ctx.rotate(loopRot);
// ground
const horizonY = 60 + slopeTilt;
const gg = ctx.createLinearGradient(0, horizonY, 0, H * 2);
gg.addColorStop(0, '#7fb069'); gg.addColorStop(1, '#3d6b35');
ctx.fillStyle = gg;
ctx.fillRect(-W, horizonY, W * 2, H * 2);
// water strip far away
ctx.fillStyle = night ? '#16204d' : '#3a7cc9';
ctx.fillRect(-W, horizonY - 14, W * 2, 10);
// scrolling ground stripes (speed feel)
const speedF = 0.5 + maxSpeed / 60;
const scroll = (rawProg * 900 * speedF) % 120;
ctx.strokeStyle = 'rgba(255,255,255,.10)';
ctx.lineWidth = 3;
for (let i = -3; i < 22; i++) {
const y = horizonY + ((i * 120 - scroll) ** 1.35) / 40;
if (y > H * 1.6) break;
ctx.beginPath();
ctx.moveTo(-W, y);
ctx.lineTo(W, y);
ctx.stroke();
}
// track rails ahead (perspective V)
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(-70, H * 0.75); ctx.quadraticCurveTo(-30, horizonY + 140, -16, horizonY + 34);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(70, H * 0.75); ctx.quadraticCurveTo(30, horizonY + 140, 16, horizonY + 34);
ctx.stroke();
// passing posts
const postScroll = (rawProg * 40) % 3;
ctx.fillStyle = 'rgba(60,64,80,.85)';
for (let i = 0; i < 6; i++) {
const k = i - postScroll;
if (k < -1) continue;
const px = (k - 2) * 260;
const ph = 90 + (i % 3) * 30;
ctx.fillRect(px, horizonY + 60, 10, ph + 200);
}
// trees silhouettes occasionally
for (let i = 0; i < 4; i++) {
const k = i - ((rawProg * 17) % 1) * 1;
const tx = ((i * 397 + Math.floor(rawProg * 17) * 131) % (W * 2)) - W / 2;
ctx.fillStyle = night ? '#101530' : '#2e6b34';
ctx.beginPath();
ctx.arc(tx, horizonY + 46, 34, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// wind speed lines at high speed
if (maxSpeed > 45) {
ctx.strokeStyle = 'rgba(255,255,255,.25)';
for (let i = 0; i < 8; i++) {
const y = Math.random() * H;
const x = Math.random() * W;
ctx.lineWidth = Math.random() * 2;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 60 + Math.random() * 90, y + (Math.random() - .5) * 10); ctx.stroke();
}
}
// vignette
const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.35, W / 2, H / 2, H);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,10,.42)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// coaster car front
ctx.fillStyle = '#e05b5b';
ctx.beginPath();
ctx.moveTo(W / 2 - 130, H + 40);
ctx.quadraticCurveTo(W / 2, H - 150, W / 2 + 130, H + 40);
ctx.closePath();
ctx.fill();
}
function clampN(v, a, b) { return v < a ? a : v > b ? b : v; }