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
+266
View File
@@ -0,0 +1,266 @@
// ============ 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,
};
}