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