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
220 lines
6.4 KiB
JavaScript
220 lines
6.4 KiB
JavaScript
// ============ rides.js — ride lifecycle, queues, cycles, breakdowns ============
|
|
import { earn } from './economy.js';
|
|
import { uid, clamp } from '../core/util.js';
|
|
import { finishRide } from './guests.js';
|
|
|
|
const rng = Math.random;
|
|
|
|
export function joinQueue(state, g, ride) {
|
|
const cost = Math.round(ride.price * g.spendMul);
|
|
if (cost > g.money) {
|
|
// can't afford — try later
|
|
g.rideCd = 10 + rng() * 8;
|
|
g.state = 'walking';
|
|
return false;
|
|
}
|
|
if (ride.queue.length >= 30) {
|
|
g.rideCd = 6;
|
|
g.state = 'walking';
|
|
return false;
|
|
}
|
|
ride.queue.push(g.id);
|
|
g.state = 'queuing';
|
|
// visual slot placement
|
|
const i = ride.queue.length - 1;
|
|
const row = Math.floor(i / 4), col = i % 4;
|
|
g.x = ride.entranceX + (col - 1.5) * 0.45;
|
|
g.y = ride.entranceY + 0.9 + row * 0.55;
|
|
return true;
|
|
}
|
|
|
|
export function updateRides(state, dt) {
|
|
for (const r of state.rides) {
|
|
r.animPhase += dt;
|
|
switch (r.status) {
|
|
case 'open': stepOpenRide(state, r, dt); break;
|
|
case 'testing': stepTestingRide(state, r, dt); break;
|
|
case 'broken': break; // waits for mechanic
|
|
}
|
|
}
|
|
}
|
|
|
|
function stepOpenRide(state, r, dt) {
|
|
// random breakdown
|
|
const hazard = 0.0022 * (1.06 - r.reliability) * (1 + (r.intensity || 2) / 8) * (r.isCustomCoaster ? 1.5 : 1);
|
|
if (rng() < dt * hazard) { breakDown(state, r); return; }
|
|
// board guests
|
|
const cap = capacityOf(r);
|
|
while (r.riders.length < cap && r.queue.length) {
|
|
const gid = r.queue.shift();
|
|
const g = state.guests.find(g => g.id === gid);
|
|
if (!g) continue;
|
|
const cost = Math.round(r.price * (g.spendMul || 1));
|
|
if ((g.money ?? 0) < cost) {
|
|
g.state = 'walking'; g.happiness = clamp(g.happiness - 3, 0, 100);
|
|
continue;
|
|
}
|
|
g.money -= cost;
|
|
earn(state, cost, 'rideTickets');
|
|
r.income += cost;
|
|
g.state = 'riding';
|
|
r.riders.push(gid);
|
|
}
|
|
// run cycle
|
|
if (r.riders.length > 0 || r.cycleT > 0) {
|
|
r.cycleT += dt;
|
|
advanceTrainIfCoaster(state, r);
|
|
if (r.cycleT >= r.cycleDur) {
|
|
unloadRide(state, r);
|
|
}
|
|
}
|
|
}
|
|
|
|
function stepTestingRide(state, r, dt) {
|
|
r.cycleT += dt;
|
|
advanceTrainIfCoaster(state, r);
|
|
if (r.cycleT >= r.cycleDur) {
|
|
r.status = 'closed';
|
|
r.cycleT = 0;
|
|
resetTrain(r);
|
|
state.toasts.push({ kind: 'good', title: `${r.name} tested OK`, text: 'You can now open it to guests.' });
|
|
}
|
|
}
|
|
|
|
function capacityOf(r) {
|
|
if (!r.isCustomCoaster) return r.def.capacity;
|
|
return Math.max(4, Math.min(r.def.capacity, Math.floor(trackLength(r) / 12) + 4));
|
|
}
|
|
|
|
function unloadRide(state, r) {
|
|
for (const gid of r.riders) {
|
|
const g = state.guests.find(g => g.id === gid);
|
|
if (!g) continue;
|
|
const fit = 1 - Math.abs((r.intensity || 2) / 10 - (g.prefThrill ?? 0.5)) * 2;
|
|
let verdict;
|
|
const score = (r.excite || 1) * clamp(fit + 0.35, 0.15, 1.25);
|
|
if ((r.intensity - (g.prefThrill * 10)) > 5.5) verdict = 'bad';
|
|
else if (score > 5.5) verdict = 'great';
|
|
else if (score > 3) verdict = 'good';
|
|
else verdict = 'meh';
|
|
g.state = 'walking';
|
|
finishRide(state, g, r, verdict);
|
|
}
|
|
r.totalRiders += r.riders.length;
|
|
r.riders = [];
|
|
r.cycleT = 0;
|
|
resetTrain(r);
|
|
// pull next waiting guests toward queue head
|
|
reflowQueue(state, r);
|
|
}
|
|
|
|
function reflowQueue(state, r) {
|
|
r.queue.forEach((gid, i) => {
|
|
const g = state.guests.find(g => g.id === gid);
|
|
if (!g) return;
|
|
const row = Math.floor(i / 4), col = i % 4;
|
|
g.x = r.entranceX + (col - 1.5) * 0.45;
|
|
g.y = r.entranceY + 0.9 + row * 0.55;
|
|
});
|
|
}
|
|
|
|
export function breakDown(state, r) {
|
|
r.status = 'broken';
|
|
r.breakdownT = 24 + rng() * 26;
|
|
r.brokenCount++;
|
|
// scare riders off
|
|
for (const gid of r.riders) {
|
|
const g = state.guests.find(g => g.id === gid);
|
|
if (g) { g.state = 'walking'; g.happiness = clamp(g.happiness - 18, 0, 100); }
|
|
}
|
|
r.riders = [];
|
|
r.queue.forEach(gid => {
|
|
const g = state.guests.find(g => g.id === gid);
|
|
if (g) { g.state = 'walking'; g.rideCd = 15; }
|
|
});
|
|
r.queue = [];
|
|
state.toasts.push({ kind: 'bad', title: `${r.name} broke down!`, text: 'Send a mechanic!' });
|
|
}
|
|
|
|
export function setRideOpen(state, r, open) {
|
|
if (open) {
|
|
if (r.status === 'broken') return false;
|
|
r.status = 'open';
|
|
r.cycleT = 0;
|
|
} else {
|
|
r.status = 'closed';
|
|
r.queue.forEach(gid => {
|
|
const g = state.guests.find(g => g.id === gid);
|
|
if (g) g.state = 'walking';
|
|
});
|
|
r.queue = [];
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function startTest(state, r) {
|
|
if (r.status === 'broken') return false;
|
|
r.status = 'testing';
|
|
r.cycleT = 0;
|
|
resetTrain(r);
|
|
return true;
|
|
}
|
|
|
|
export function removeRide(state, r) {
|
|
// refund half construction value
|
|
const refund = Math.round(r.def.cost * 0.5 * (r.isCustomCoaster ? 0.6 : 1));
|
|
earn(state, refund, 'misc');
|
|
for (let yy = 0; yy < r.h; yy++) for (let xx = 0; xx < r.w; xx++) {
|
|
const o = state.map.getObject(r.x + xx, r.y + yy);
|
|
if (o && o.kind === 'ride' && o.id === r.id) state.map.clearObject(r.x + xx, r.y + yy);
|
|
}
|
|
// free track cells
|
|
if (r.track) {
|
|
for (let i = 0; i < r.track.length; i++) {
|
|
const t = r.track[i];
|
|
const o = state.map.getObject(t.x, t.y);
|
|
if (o && o.kind === 'track') state.map.clearObject(t.x, t.y);
|
|
}
|
|
}
|
|
[...r.queue, ...r.riders].forEach(gid => {
|
|
const g = state.guests.find(g => g.id === gid);
|
|
if (g) g.state = 'walking';
|
|
});
|
|
state.rides = state.rides.filter(x => x !== r);
|
|
return refund;
|
|
}
|
|
|
|
// ---------------- custom coaster train animation ----------------
|
|
export function trackLength(r) {
|
|
return r.track ? r.track.length : 0;
|
|
}
|
|
function resetTrain(r) {
|
|
if (r.train) { r.train.pos = 0; r.train.speed = 0; r.train.done = false; }
|
|
}
|
|
function advanceTrainIfCoaster(state, r) {
|
|
if (!r.isCustomCoaster || !r.track?.length) return;
|
|
const tr = r.train;
|
|
if (!tr) return;
|
|
const frac = r.cycleT / r.cycleDur;
|
|
tr.progress = clamp(frac, 0, 1); // renderer interpolates along track
|
|
}
|
|
|
|
/** Compute per-tick position of coaster train for smooth animation */
|
|
export function trainPosition(r) {
|
|
if (!r.track || !r.track.length) return null;
|
|
const prog = r.train?.progress ?? 0;
|
|
const f = prog * (r.track.length - 1);
|
|
const i = Math.floor(f);
|
|
const t = f - i;
|
|
const a = r.track[Math.min(i, r.track.length - 1)];
|
|
const b = r.track[Math.min(i + 1, r.track.length - 1)];
|
|
return {
|
|
x: a.x + (b.x - a.x) * t,
|
|
y: a.y + (b.y - a.y) * t,
|
|
z: a.z + (b.z - a.z) * t,
|
|
loop: a.loop ? (t < 0.5 ? t * 2 : 2 - t * 2) : 0,
|
|
dirIdx: i % r.track.length,
|
|
piece: a,
|
|
};
|
|
}
|