// ============ 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, }; }