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:
+181
@@ -0,0 +1,181 @@
|
||||
// ============ 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; }
|
||||
Reference in New Issue
Block a user