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
+208
View File
@@ -0,0 +1,208 @@
// ============ staff.js — handymen, mechanics, guards, jesters ============
import { STAFF_TYPES } from '../core/config.js';
import { uid, clamp, choice } from '../core/util.js';
import { findPath, randomNearbyPath, snapToPath } from '../world/path.js';
import { pay } from './economy.js';
const rng = Math.random;
export function hireStaff(state, typeId) {
const def = STAFF_TYPES[typeId];
if (!def || state.cash < 100) return null;
pay(state, 100, 'wages', 'hire');
// spawn at entrance plaza
const m = state.map;
const x = m.entranceX + Math.floor(rng() * 3 - 1), y = m.entranceY - 3;
const s = {
id: uid(), kind: 'staff', type: typeId, def,
name: `${def.name} #${state.staff.filter(q => q.type === typeId).length + 1}`,
x, y, path: [], state: 'idle',
taskT: 0, workT: 0, targetTask: null,
speed: 1.6 + rng() * 0.5, wage: def.wage,
};
state.staff.push(s);
return s;
}
export function fireStaff(state, id) {
const i = state.staff.findIndex(s => s.id === id);
if (i >= 0) { state.staff.splice(i, 1); return true; }
return false;
}
export function updateStaff(state, dt) {
for (const s of state.staff) stepStaff(state, s, dt);
}
function stepStaff(state, s, dt) {
if (s.path.length) {
moveAlong(s, dt);
if (!s.path.length) onArrive(state, s);
return;
}
s.taskT -= dt;
if (s.state === 'working') {
s.workT -= dt;
doWork(state, s, dt);
if (s.workT <= 0) { s.state = 'idle'; s.taskT = 0.4; }
return;
}
if (s.taskT > 0) return;
findTask(state, s);
}
function moveAlong(s, dt) {
const [tx, ty] = s.path[0];
const dx = tx - s.x, dy = ty - s.y;
const d = Math.hypot(dx, dy);
const stepLen = s.speed * dt;
if (d <= stepLen) { s.x = tx; s.y = ty; s.path.shift(); }
else { s.x += dx / d * stepLen; s.y += dy / d * stepLen; }
}
function onArrive(state, s) {
if (!s.targetTask) { s.state = 'idle'; s.taskT = 0.5; return; }
s.state = 'working';
s.workT = s.targetTask.kind === 'repair' ? 3 : 2.2;
}
function findTask(state, s) {
switch (s.type) {
case 'handyman': findCleanTask(state, s); break;
case 'mechanic': findRepairTask(state, s); break;
case 'guard': patrolNear(state, s, 'shop'); break;
case 'entertainer': patrolNear(state, s, 'ride'); break;
}
if (!s.path?.length && !s.targetTask) s.taskT = 1 + rng() * 2;
}
function walkTo(state, s, tx, ty) {
const [sx, sy] = snapToPath(state.map, s.x, s.y);
const p = findPath(state.map, sx, sy, tx, ty, 5000);
if (p) s.path = p;
else {
// staff can walk anywhere slowly (they know shortcuts)
s.x = tx; s.y = ty; // teleport fallback keeps game playable
}
}
// handyman: nearest litter/vomit tile
function findCleanTask(state, s) {
let best = null, bestD = Infinity;
const m = state.map;
const cx = Math.round(s.x), cy = Math.round(s.y);
const R = 26;
for (let y = Math.max(0, cy - R); y < Math.min(m.size, cy + R); y++) {
for (let x = Math.max(0, cx - R); x < Math.min(m.size, cx + R); x++) {
const i = m.idx(x, y);
if (!m.pathType[i]) continue;
const dirt = m.litter[i] + m.vomit[i] * 2;
if (dirt < 0.3) continue;
const d = Math.hypot(x - cx, y - cy);
if (d < bestD) { bestD = d; best = [x, y]; }
}
}
if (best) {
s.targetTask = { kind: 'clean' };
walkTo(state, s, best[0], best[1]);
} else if (rng() < 0.25) {
wanderStaff(state, s);
}
}
// mechanic: broken ride needing repair
function findRepairTask(state, s) {
const broken = state.rides.filter(r => r.status === 'broken');
if (!broken.length) {
// preventive maintenance on random ride
if (rng() < 0.02 && state.rides.length) {
const r = choice(rng, state.rides);
const adj = entranceAdj(state, r);
if (adj) { s.targetTask = { kind: 'service', ride: r.id }; walkTo(state, s, adj[0], adj[1]); }
}
return;
}
const r = broken[0];
const adj = entranceAdj(state, r);
if (adj) { s.targetTask = { kind: 'repair', ride: r.id }; walkTo(state, s, adj[0], adj[1]); }
}
function entranceAdj(state, r) {
const cands = [[r.entranceX + 1, r.entranceY], [r.entranceX - 1, r.entranceY], [r.entranceX, r.entranceY + 1], [r.entranceX, r.entranceY - 1]];
for (const c of cands) if (state.map.isPath(c[0], c[1])) return c;
return null;
}
function patrolNear(state, s, kind) {
let pool;
if (kind === 'shop') pool = [...state.shops];
else pool = state.rides.filter(r => r.status === 'open' && r.queue.length > 0);
if (!pool.length) pool = state.rides.concat(state.shops.map(s2 => ({ entranceX: s2.x, entranceY: s2.y })));
if (!pool.length) { wanderStaff(state, s); return; }
const t = choice(rng, pool);
const ex = t.entranceX ?? t.x, ey = t.entranceY ?? t.y;
const cands = [[ex + 1, ey], [ex - 1, ey], [ex, ey + 1], [ex, ey - 1]].filter(c => state.map.isPath(c[0], c[1]));
if (!cands.length) { wanderStaff(state, s); return; }
const c = choice(rng, cands);
s.targetTask = { kind: kind === 'shop' ? 'guard' : 'entertain', x: c[0], y: c[1] };
walkTo(state, s, c[0], c[1]);
}
function wanderStaff(state, s) {
const dest = randomNearbyPath(state.map, rng, Math.round(s.x), Math.round(s.y), 3, 10);
if (dest) walkTo(state, s, dest[0], dest[1]);
}
function doWork(state, s, dt) {
const task = s.targetTask;
if (!task) return;
const m = state.map;
switch (task.kind) {
case 'clean': {
const i = m.idx(Math.round(s.x), Math.round(s.y));
m.litter[i] = Math.max(0, m.litter[i] - dt * 0.9);
m.vomit[i] = Math.max(0, m.vomit[i] - dt * 0.7);
break;
}
case 'repair': {
const r = state.rides.find(r => r.id === task.ride);
if (r && r.status === 'broken') {
r.breakdownT -= dt * 2;
if (r.breakdownT <= 0) fixRide(state, r);
}
break;
}
case 'service': {
const r = state.rides.find(r => r.id === task.ride);
if (r) r.reliability = clamp(r.reliability + dt * 0.05, 0, 0.99);
break;
}
case 'guard': {
state.vandalism = Math.max(0, state.vandalism - dt * 0.08);
break;
}
case 'entertain': {
for (const gid of collectQueuedGuests(state, s.x, s.y)) {
const g = state.guests.find(g => g.id === gid);
if (g) g.happiness = clamp(g.happiness + dt * 1.8, 0, 100);
}
break;
}
}
}
function collectQueuedGuests(state, x, y) {
const out = [];
for (const r of state.rides) {
if (Math.hypot(r.entranceX - x, r.entranceY - y) > 12) continue;
out.push(...r.queue);
}
return out.slice(0, 30);
}
export function fixRide(state, r) {
r.status = 'closed'; // reopen manually or auto after check
r.breakdownT = 0;
r.brokenCount++;
state.toasts.push({ kind: 'good', title: `${r.name} repaired`, text: 'Ready to reopen.' });
}