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:
@@ -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.' });
|
||||
}
|
||||
Reference in New Issue
Block a user