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
182 lines
6.1 KiB
JavaScript
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; }
|