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:
2026-08-23 06:59:21 +00:00
commit ac00687480
30 changed files with 6772 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
// ============ 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,
};
}