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
267 lines
10 KiB
JavaScript
267 lines
10 KiB
JavaScript
// ============ coaster.js — custom roller coaster designer & physics ============
|
|
import { PIECES, DIRS, MAX_Z, MIN_COASTER_PIECES } from '../core/config.js';
|
|
import { pay } from './economy.js';
|
|
import { clamp } from '../core/util.js';
|
|
import { addRideObj } from './state.js';
|
|
|
|
const rng = Math.random;
|
|
|
|
// ---------------- build session ----------------
|
|
export function startCoasterSession(state, x, y, dir) {
|
|
// station must touch a path tile so guests can reach it
|
|
const okAdj = [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]].some(c => state.map.isPath(c[0], c[1]));
|
|
if (!okAdj) return { error: 'The station must be adjacent to a path.' };
|
|
if (!state.map.isBuildable(x, y) || state.map.occupied(x, y)) return { error: 'Blocked location.' };
|
|
const session = {
|
|
active: true,
|
|
pieces: [{ type: 'station', x, y, z: 0, dir, lift: false }],
|
|
cx: x, cy: y, cz: 0, cdir: dir,
|
|
spent: 300,
|
|
name: 'Custom Coaster ' + (state.rides.filter(r => r.isCustomCoaster).length + 1),
|
|
trainColor: '#e05b5b',
|
|
};
|
|
state.map.setObject(x, y, { kind: 'track', id: -1 });
|
|
state._coasterBuild = session;
|
|
return session;
|
|
}
|
|
|
|
export function sessionActive(state) { return !!state._coasterBuild?.active; }
|
|
export function getSession(state) { return state._coasterBuild || null; }
|
|
|
|
export function nextCellFor(session, pieceId) {
|
|
const def = PIECES[pieceId];
|
|
const [dx, dy] = DIRS[session.cdir];
|
|
return {
|
|
x: session.cx + dx,
|
|
y: session.cy + dy,
|
|
z: session.cz + def.dz,
|
|
dir: (session.cdir + def.turn + 4) % 4,
|
|
};
|
|
}
|
|
|
|
export function validatePiece(state, session, pieceId) {
|
|
const def = PIECES[pieceId];
|
|
const nc = nextCellFor(session, pieceId);
|
|
const m = state.map;
|
|
if (!m.inBounds(nc.x, nc.y)) return { ok: false, reason: 'Outside the park bounds' };
|
|
if (m.terrainAt(nc.x, nc.y) === 3) return { ok: false, reason: "Can't build over water" };
|
|
if (m.objects[m.idx(nc.x, nc.y)]) {
|
|
const o = m.getObject(nc.x, nc.y);
|
|
if (!(o.kind === 'track')) return { ok: false, reason: 'Blocked by ' + o.kind };
|
|
return { ok: false, reason: 'Track already here' };
|
|
}
|
|
if (nc.z < 0) return { ok: false, reason: "Can't go underground" };
|
|
if (nc.z > MAX_Z) return { ok: false, reason: 'Too high!' };
|
|
return { ok: true, cell: nc };
|
|
}
|
|
|
|
export function pieceCost(state, pieceId) {
|
|
let c = PIECES[pieceId].cost;
|
|
if (state.spells.active.swift_build) c *= 0.5;
|
|
return Math.round(c);
|
|
}
|
|
|
|
export function addPiece(state, pieceId) {
|
|
const session = getSession(state);
|
|
if (!session) return { error: 'No active session' };
|
|
const v = validatePiece(state, session, pieceId);
|
|
if (!v.ok) return { error: v.reason };
|
|
const def = PIECES[pieceId];
|
|
const cost = pieceCost(state, pieceId);
|
|
if (!state.sandbox && state.cash < cost) return { error: 'Not enough money' };
|
|
if (!state.sandbox) pay(state, cost, 'construction');
|
|
session.spent += cost;
|
|
// lift hill: ascending pieces before the first descent
|
|
const hasDrop = session.pieces.some(p => p.type === 'down' || p.type === 'steepDown');
|
|
const lift = !hasDrop && (pieceId === 'up' || pieceId === 'steepUp');
|
|
session.pieces.push({ type: pieceId, ...v.cell, lift });
|
|
session.cx = v.cell.x; session.cy = v.cell.y; session.cz = v.cell.z; session.cdir = v.cell.dir;
|
|
state.map.setObject(v.cell.x, v.cell.y, { kind: 'track', id: -1 });
|
|
state.map.pathType[state.map.idx(v.cell.x, v.cell.y)] = 0;
|
|
return { ok: true };
|
|
}
|
|
|
|
export function undoPiece(state) {
|
|
const session = getSession(state);
|
|
if (!session || session.pieces.length <= 1) return false;
|
|
const last = session.pieces.pop();
|
|
state.map.clearObject(last.x, last.y);
|
|
// recompute cursor from new last piece
|
|
const cur = session.pieces[session.pieces.length - 1];
|
|
const def = PIECES[cur.type];
|
|
session.cx = cur.x; session.cy = cur.y; session.cz = cur.z; session.cdir = cur.dir;
|
|
session.spent = Math.max(300, session.spent - PIECES[last.type].cost);
|
|
return true;
|
|
}
|
|
|
|
export function cancelCoaster(state) {
|
|
const session = getSession(state);
|
|
if (!session) return;
|
|
for (const p of session.pieces) state.map.clearObject(p.x, p.y);
|
|
state._coasterBuild = null;
|
|
}
|
|
|
|
export function isCircuitClosed(session) {
|
|
const st = session.pieces[0];
|
|
const [dx, dy] = [DIRS[st.dir][0], DIRS[st.dir][1]];
|
|
return (
|
|
session.cx === st.x - dx && session.cy === st.y - dy &&
|
|
session.cz === st.z && session.cdir === st.dir &&
|
|
session.pieces.length >= MIN_COASTER_PIECES
|
|
);
|
|
}
|
|
|
|
export function finishCoaster(state) {
|
|
const session = getSession(state);
|
|
if (!session) return { error: 'No active session' };
|
|
if (session.pieces.length < MIN_COASTER_PIECES) return { error: `Need at least ${MIN_COASTER_PIECES} pieces` };
|
|
if (!isCircuitClosed(session)) {
|
|
return { error: 'The track must form a complete circuit back to the station!' };
|
|
}
|
|
const stats = computeStats(session.pieces);
|
|
// choose an entrance piece that touches an external path (guests must reach it)
|
|
const DIR4 = DIRS;
|
|
let entPiece = session.pieces[0], bestEntD = Infinity;
|
|
for (const p of session.pieces) {
|
|
for (const [dx, dy] of DIR4) {
|
|
if (state.map.isPath(p.x + dx, p.y + dy)) {
|
|
const st0 = session.pieces[0];
|
|
const d = Math.abs(p.x - st0.x) + Math.abs(p.y - st0.y);
|
|
if (d < bestEntD) { bestEntD = d; entPiece = p; }
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (bestEntD === Infinity) {
|
|
state.toasts.push({ kind: 'info', title: `${session.name} built`, text: 'Tip: connect a path next to the track so guests can queue!' });
|
|
}
|
|
// bounding box footprint
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const p of session.pieces) {
|
|
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
|
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
|
|
}
|
|
const w = maxX - minX + 1, h = maxY - minY + 1;
|
|
const station = session.pieces[0];
|
|
const ride = addRideObj(state, 'dragon_coaster', minX, minY, {
|
|
name: session.name,
|
|
coaster: true,
|
|
track: session.pieces.map(p => ({ ...p })),
|
|
stats,
|
|
entranceX: entPiece.x, entranceY: entPiece.y,
|
|
});
|
|
// override footprint fields set by addRideObj from def
|
|
ride.w = w; ride.h = h;
|
|
ride.excite = stats.excitement; ride.intensity = stats.intensity; ride.nausea = stats.nausea;
|
|
ride.cycleDur = stats.rideTime;
|
|
ride.price = Math.max(2, Math.round(stats.excitement * 1.2));
|
|
ride.train = { progress: 0, color: session.trainColor };
|
|
// re-mark all track cells to this ride
|
|
for (let i = 0; i < session.pieces.length; i++) {
|
|
const p = session.pieces[i];
|
|
state.map.setObject(p.x, p.y, { kind: 'track', id: ride.id, pi: i });
|
|
}
|
|
// ensure footprint cells (non-track inside bbox) belong to ride too
|
|
const covered = new Set(session.pieces.map(p => `${p.x},${p.y}`));
|
|
for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) {
|
|
const key = `${minX + xx},${minY + yy}`;
|
|
if (!covered.has(key) && state.map.inBounds(minX + xx, minY + yy) && !state.map.objects[state.map.idx(minX + xx, minY + yy)]) {
|
|
state.map.setObject(minX + xx, minY + yy, { kind: 'ride', id: ride.id, ox: xx, oy: yy });
|
|
}
|
|
}
|
|
state._coasterBuild = null;
|
|
state.toasts.push({ kind: 'gold', title: `${ride.name} built!`, text: `Excitement ${stats.excitement.toFixed(1)} · Intensity ${stats.intensity.toFixed(1)} · Nausea ${stats.nausea.toFixed(1)} — test & open it!` });
|
|
return { ok: true, ride };
|
|
}
|
|
|
|
// ---------------- physics / rating ----------------
|
|
const G_ACC = 9.81 * 2.5; // 1 z-unit ≈ 2.5 m
|
|
export function computeStats(pieces) {
|
|
let v = 0; // m/s
|
|
let maxV = 0, sumV = 0;
|
|
let inversions = 0, drops = 0, biggestDrop = 0;
|
|
let turns = 0, straights = 0;
|
|
let prevZ = 0, peakZ = 0, dropFrom = 0;
|
|
let airPieces = 0;
|
|
const n = pieces.length;
|
|
for (let i = 0; i < n; i++) {
|
|
const p = pieces[i];
|
|
const dzM = (p.z - prevZ) * 2.5;
|
|
prevZ = p.z;
|
|
peakZ = Math.max(peakZ, p.z);
|
|
switch (p.type) {
|
|
case 'loop': inversions++; break;
|
|
case 'curveL': case 'curveR': turns++; break;
|
|
case 'straight': case 'station': straights++; break;
|
|
case 'up': case 'steepUp':
|
|
if (!p.lift) v = Math.sqrt(Math.max(0, v * v - 2 * G_ACC * dzM));
|
|
else v = 7; // chain lift crawl
|
|
break;
|
|
case 'down': case 'steepDown': {
|
|
if (p.type === 'down') drops++; else { drops++; }
|
|
dropFrom = peakZ;
|
|
const gainV = Math.sqrt(Math.max(0, v * v + 2 * G_ACC * (-dzM)));
|
|
if (gainV > v + 12 && p.type === 'steepDown') airPieces++;
|
|
v = gainV;
|
|
break;
|
|
}
|
|
}
|
|
// friction & limits
|
|
v *= 0.992;
|
|
if (v > 38) v = 38; // safety limit
|
|
if (v < 4 && !(p.lift)) v = 4; // anti-stall handbrake
|
|
maxV = Math.max(maxV, v);
|
|
sumV += v;
|
|
if (p.type === 'station' && i > 0) v = Math.max(v, 6); // station brake run
|
|
}
|
|
const avgV = sumV / n;
|
|
const totalDrop = Math.max(peakZ, 1);
|
|
const lenScore = clamp(n / 40, 0, 1.6);
|
|
const speedScore = maxV / 26;
|
|
let excitement = 1.2 +
|
|
lenScore * 1.9 +
|
|
speedScore * 2.1 +
|
|
inversions * 1.15 +
|
|
Math.min(drops, 8) * 0.45 +
|
|
airPieces * 0.55 +
|
|
Math.min(turns, 10) * 0.16 +
|
|
totalDrop / 22;
|
|
excitement = clamp(excitement, 0.5, 10);
|
|
let intensity = 0.8 + speedScore * 2.6 + inversions * 0.9 + airPieces * 0.7 + totalDrop / 14 + Math.min(drops, 6) * 0.25;
|
|
intensity = clamp(intensity, 0.4, 10);
|
|
let nausea = 0.5 + turns / n * 6 + inversions * 1.1 + (turns > n * 0.45 ? 1.5 : 0) + airPieces * 0.35;
|
|
nausea = clamp(nausea, 0.3, 10);
|
|
const rideTime = clamp(6 + n * (avgV > 18 ? 0.75 : 1.05), 8, 120);
|
|
const maxSpeedKmh = Math.round(maxV * 3.6);
|
|
return {
|
|
excitement: round1(excitement), intensity: round1(intensity), nausea: round1(nausea),
|
|
maxSpeed: maxSpeedKmh, avgSpeed: Math.round(avgV * 3.6),
|
|
drops, inversions, rideTime: Math.round(rideTime), length: n, maxHeight: peakZ,
|
|
airtimePieces: airPieces,
|
|
};
|
|
}
|
|
function round1(x) { return Math.round(x * 10) / 10; }
|
|
|
|
/** sample a point along the track for POV / animation */
|
|
export function sampleTrack(track, t01) {
|
|
if (!track.length) return null;
|
|
const f = t01 * (track.length - 1);
|
|
const i = clamp(Math.floor(f), 0, track.length - 1);
|
|
const t = f - i;
|
|
const a = track[i];
|
|
const b = track[Math.min(i + 1, track.length - 1)];
|
|
// direction angle in screen space (iso-ish approximation for POV)
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
const screenAng = Math.atan2((dx + dy), (dx - dy) * 0.5);
|
|
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,
|
|
turn: a.turn ?? 0,
|
|
slope: (b.z - a.z),
|
|
loop: a.type === 'loop',
|
|
ang: screenAng,
|
|
type: a.type,
|
|
};
|
|
}
|