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,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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// ============ economy.js — money, finance cycles, marketing ============
|
||||
import { fmtMoney } from '../core/util.js';
|
||||
|
||||
export function pay(state, amount, category, note) {
|
||||
state.cash -= amount;
|
||||
pushFin(state, category, -amount);
|
||||
return true;
|
||||
}
|
||||
export function earn(state, amount, category, note) {
|
||||
state.cash += amount;
|
||||
pushFin(state, category, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function pushFin(state, category, amount) {
|
||||
const m = state.finance.current;
|
||||
m[category] = (m[category] || 0) + amount;
|
||||
}
|
||||
|
||||
export const FIN_CATEGORIES = [
|
||||
['rideTickets', 'Ride tickets'],
|
||||
['shopSales', 'Shop sales'],
|
||||
['entrance', 'Entrance fees'],
|
||||
['construction', 'Construction'],
|
||||
['wages', 'Staff wages'],
|
||||
['research', 'Research'],
|
||||
['marketing', 'Marketing'],
|
||||
['heroes', 'Heroes & gear'],
|
||||
['loot', 'Monster loot'],
|
||||
['runCosts', 'Ride running costs'],
|
||||
['misc', 'Misc'],
|
||||
['loans', 'Loans'],
|
||||
];
|
||||
|
||||
/** Monthly finance close: wages, interest, archive */
|
||||
export function monthClose(state) {
|
||||
// wages
|
||||
let wages = 0;
|
||||
for (const s of state.staff) wages += s.wage;
|
||||
for (const r of state.rides) wages += Math.round(r.def.runCost);
|
||||
pay(state, wages, 'wages');
|
||||
// loan interest 1%/month
|
||||
if (state.loan > 0) {
|
||||
const interest = Math.ceil(state.loan * 0.01);
|
||||
pay(state, interest, 'loans');
|
||||
}
|
||||
// archive current month
|
||||
const cur = { ...state.finance.current };
|
||||
state.finance.history.push(cur);
|
||||
if (state.finance.history.length > 36) state.finance.history.shift();
|
||||
state.finance.current = {};
|
||||
state.lastMonthProfit = Object.entries(cur).reduce((a, [, v]) => a + v, 0);
|
||||
return wages;
|
||||
}
|
||||
|
||||
// ---------------- Marketing campaigns ----------------
|
||||
export const CAMPAIGNS = [
|
||||
{ id: 'flyers', name: 'Fairy Flyer Drop', cost: 400, weeks: 6, pull: 1.6 },
|
||||
{ id: 'heralds', name: 'Town Crier Heralds', cost: 900, weeks: 8, pull: 2.4 },
|
||||
{ id: 'crystal', name: 'Crystal Ball Vision', cost: 1800, weeks: 10, pull: 3.6 },
|
||||
];
|
||||
|
||||
export function startCampaign(state, id) {
|
||||
const c = CAMPAIGNS.find(c => c.id === id);
|
||||
if (!c || state.cash < c.cost) return false;
|
||||
pay(state, c.cost, 'marketing');
|
||||
state.campaigns.push({ id: c.id, name: c.name, weeksLeft: c.weeks, pull: c.pull });
|
||||
return true;
|
||||
}
|
||||
export function campaignPull(state) {
|
||||
let p = 0;
|
||||
for (const c of state.campaigns) p += c.pull;
|
||||
return p;
|
||||
}
|
||||
export function tickCampaigns(state) {
|
||||
for (let i = state.campaigns.length - 1; i >= 0; i--) {
|
||||
state.campaigns[i].weeksLeft--;
|
||||
if (state.campaigns[i].weeksLeft <= 0) state.campaigns.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Loan ----------------
|
||||
export function takeLoan(state, amount) {
|
||||
amount = Math.min(amount, state.loanLimit - state.loan);
|
||||
if (amount <= 0) return false;
|
||||
state.loan += amount;
|
||||
earn(state, amount, 'loans');
|
||||
return true;
|
||||
}
|
||||
export function repayLoan(state, amount) {
|
||||
amount = Math.min(amount, state.loan, state.cash);
|
||||
if (amount <= 0) return false;
|
||||
state.loan -= amount;
|
||||
pay(state, amount, 'loans');
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// ============ guests.js — guest spawning, needs AI, spending ============
|
||||
import { GUEST_NAMES, GUEST_COLORS, THOUGHTS, WEATHER } from '../core/config.js';
|
||||
import { makeRng, choice, clamp, uid } from '../core/util.js';
|
||||
import { findPath, randomNearbyPath, snapToPath } from '../world/path.js';
|
||||
import { earn } from './economy.js';
|
||||
import { isNight } from './state.js';
|
||||
|
||||
const rng = Math.random; // guests tolerate non-seeded rng
|
||||
|
||||
export function spawnGuest(state) {
|
||||
const m = state.map;
|
||||
const ex = m.entranceX, ey = m.entranceY;
|
||||
// entrance fee check
|
||||
const willing = state.stats.rating * 0.45;
|
||||
const fee = state.park.entranceFee;
|
||||
if (fee > 0 && fee > willing && rng() > 0.25) return null; // refuses steep fees
|
||||
const g = {
|
||||
id: uid(), kind: 'guest',
|
||||
name: choice(rng, GUEST_NAMES) + ' ' + String.fromCharCode(65 + Math.floor(rng() * 26)) + '.',
|
||||
color: choice(rng, GUEST_COLORS),
|
||||
x: ex + (rng() - 0.5) * 1.6, y: ey + 2.5,
|
||||
path: [], state: 'entering', target: null,
|
||||
hunger: 15 + rng() * 30, thirst: 15 + rng() * 30, toilet: rng() * 20, energy: 70 + rng() * 30,
|
||||
happiness: 55 + rng() * 25, money: 60 + Math.floor(rng() * 140),
|
||||
spendMul: 0.8 + rng() * 0.7,
|
||||
prefThrill: rng(), // 0 = gentle lover, 1 = thrill seeker
|
||||
thoughts: [], bubble: null, bubbleT: 0,
|
||||
rideCd: 4 + rng() * 10,
|
||||
paidEntrance: 0,
|
||||
ridesCount: 0, favRide: null,
|
||||
speed: 1.7 + rng() * 0.9,
|
||||
fleeingT: 0,
|
||||
};
|
||||
if (fee > 0 && fee <= g.money) {
|
||||
g.money -= fee; g.paidEntrance = fee;
|
||||
earn(state, fee, 'entrance');
|
||||
}
|
||||
state.guests.push(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
export function addThought(g, key, extra = {}) {
|
||||
const pool = THOUGHTS[key];
|
||||
if (!pool) return;
|
||||
let text = choice(rng, pool);
|
||||
if (extra.r) text = text.replace('{r}', extra.r);
|
||||
g.thoughts.unshift(text);
|
||||
if (g.thoughts.length > 6) g.thoughts.pop();
|
||||
g.bubble = text; g.bubbleT = 4 + rng() * 3;
|
||||
}
|
||||
|
||||
/** spawn accumulator driven by park conditions */
|
||||
export function updateSpawning(state, dt) {
|
||||
if (!state.park.open || isNight(state.time)) return;
|
||||
if (state.spells.active.warding_sigil) { /* ward doesn't block guests */ }
|
||||
const cap = state.sandbox ? 380 : 320;
|
||||
if (state.guests.length >= cap) return;
|
||||
const t = state.time.hour;
|
||||
let curve = 0;
|
||||
if (t < 9) curve = (t - 7) / 3;
|
||||
else if (t < 11) curve = 0.7;
|
||||
else if (t < 17) curve = 1.0;
|
||||
else if (t < 19) curve = 0.6;
|
||||
else curve = Math.max(0, (20.5 - t) / 2);
|
||||
if (curve <= 0) return;
|
||||
const w = WEATHER[state.weather.cur];
|
||||
let pull = campaignPullCache(state);
|
||||
const ratingFactor = clamp(state.stats.rating / 400, 0.2, 1.6);
|
||||
let rate = (0.55 + pull * 0.22) * curve * w.spawnMul * ratingFactor;
|
||||
state.guestSpawnAcc += rate * dt;
|
||||
while (state.guestSpawnAcc >= 1) {
|
||||
state.guestSpawnAcc -= 1;
|
||||
if (state.guests.length >= cap) break;
|
||||
spawnGuest(state);
|
||||
}
|
||||
}
|
||||
let _pullCache = { t: 0, v: 0 };
|
||||
function campaignPullCache(state) {
|
||||
if (!state.campaigns.length) return 0;
|
||||
if (performance.now() - _pullCache.t > 2000) { _pullCache.t = performance.now(); }
|
||||
let p = 0; for (const c of state.campaigns) p += c.pull;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------- per-guest update ----------------
|
||||
export function updateGuests(state, dt) {
|
||||
updateSpawning(state, dt);
|
||||
const m = state.map;
|
||||
for (let i = state.guests.length - 1; i >= 0; i--) {
|
||||
const g = state.guests[i];
|
||||
stepGuest(state, g, dt);
|
||||
if (g.state === 'gone') state.guests.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function stepGuest(state, g, dt) {
|
||||
const m = state.map;
|
||||
if (g.rideCd > 0) g.rideCd -= dt;
|
||||
// bubble timer
|
||||
if (g.bubbleT > 0) g.bubbleT -= dt;
|
||||
// needs rise
|
||||
g.hunger = clamp(g.hunger + dt * 0.55, 0, 100);
|
||||
g.thirst = clamp(g.thirst + dt * 0.75, 0, 100);
|
||||
g.toilet = clamp(g.toilet + dt * 0.42 * (g.thirst / 60 + 0.5), 0, 100);
|
||||
if (g.state !== 'riding') g.energy = clamp(g.energy - dt * 0.28, 0, 100);
|
||||
// flee override
|
||||
if (g.fleeingT > 0) {
|
||||
g.fleeingT -= dt;
|
||||
moveAlong(state, g, dt, 1.8);
|
||||
if (g.path.length) return;
|
||||
if (g.fleeingT > 0) wanderTo(state, g, 6);
|
||||
return;
|
||||
}
|
||||
// happiness dynamics
|
||||
let hd = -dt * 0.06;
|
||||
if (g.hunger > 85 || g.thirst > 88 || g.toilet > 90) hd -= dt * 0.5;
|
||||
hd -= WEATHER[state.weather.cur].happyDrain * dt;
|
||||
if (state.stats.magicCount > 0) hd += dt * 0.01 * Math.min(state.stats.magicCount, 10);
|
||||
if (state.spells.active.joy_aura) hd += dt * 1.6;
|
||||
g.happiness = clamp(g.happiness + hd, 0, 100);
|
||||
|
||||
switch (g.state) {
|
||||
case 'entering': {
|
||||
// walk into plaza then start deciding
|
||||
moveAlong(state, g, dt);
|
||||
if (!g.path.length) { g.state = 'walking'; decide(state, g); }
|
||||
break;
|
||||
}
|
||||
case 'walking': {
|
||||
moveAlong(state, g, dt);
|
||||
if (!g.path.length) arrive(state, g);
|
||||
break;
|
||||
}
|
||||
case 'buying': {
|
||||
g.targetT -= dt;
|
||||
if (g.targetT <= 0) completePurchase(state, g);
|
||||
break;
|
||||
}
|
||||
case 'resting': {
|
||||
g.targetT -= dt;
|
||||
g.energy = clamp(g.energy + dt * 6, 0, 100);
|
||||
g.happiness = clamp(g.happiness + dt * 0.4, 0, 100);
|
||||
if (g.targetT <= 0) { g.state = 'walking'; decide(state, g); }
|
||||
break;
|
||||
}
|
||||
case 'queuing': case 'riding':
|
||||
// managed by rides module
|
||||
break;
|
||||
case 'leaving': {
|
||||
moveAlong(state, g, dt);
|
||||
if (!g.path.length) {
|
||||
if (g.y > state.map.size - 2.2) g.state = 'gone';
|
||||
else decideLeave(state, g);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// random leave chance late day
|
||||
if (isNight(state.time) && (g.state === 'walking') && rng() < dt * 0.25) decideLeave(state, g);
|
||||
if ((g.happiness < 12 || g.money < 3) && g.state === 'walking' && rng() < dt * 0.08) {
|
||||
if (g.happiness < 12) addThought(g, g.money < 3 ? 'broke' : 'happy_park');
|
||||
decideLeave(state, g);
|
||||
}
|
||||
}
|
||||
|
||||
function moveAlong(state, g, dt, mul = 1) {
|
||||
if (!g.path.length) return;
|
||||
const [tx, ty] = g.path[0];
|
||||
const dx = tx - g.x, dy = ty - g.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const stepLen = g.speed * mul * dt;
|
||||
if (d <= stepLen) {
|
||||
g.x = tx; g.y = ty; g.path.shift();
|
||||
} else {
|
||||
g.x += dx / d * stepLen; g.y += dy / d * stepLen;
|
||||
}
|
||||
}
|
||||
|
||||
function arrive(state, g) {
|
||||
const tgt = g.target;
|
||||
if (!tgt) { decide(state, g); return; }
|
||||
if (tgt.kind === 'shop') {
|
||||
const shop = state.shops.find(s => s.id === tgt.id);
|
||||
if (!shop) { decide(state, g); return; }
|
||||
if (shop.damaged > 0) { addThought(g, 'vandal'); decide(state, g); return; }
|
||||
g.state = 'buying'; g.targetT = 1.2;
|
||||
return;
|
||||
}
|
||||
if (tgt.kind === 'bench') {
|
||||
g.state = 'resting'; g.targetT = 4 + rng() * 4;
|
||||
return;
|
||||
}
|
||||
if (tgt.kind === 'ride') {
|
||||
const ride = state.rides.find(r => r.id === tgt.id);
|
||||
if (!ride || ride.status !== 'open') { decide(state, g); return; }
|
||||
joinQueue(state, g, ride);
|
||||
return;
|
||||
}
|
||||
decide(state, g);
|
||||
}
|
||||
|
||||
export function finishRide(state, g, ride, verdict) {
|
||||
g.ridesCount++;
|
||||
g.rideCd = 8 + rng() * 14;
|
||||
g.energy = clamp(g.energy - 6, 0, 100);
|
||||
// excitement enjoyment depends on taste match
|
||||
const fit = 1 - Math.abs(ride.intensity / 10 - g.prefThrill);
|
||||
let gain = (verdict === 'great' ? 14 : verdict === 'good' ? 9 : verdict === 'meh' ? 3 : -6) * clamp(fit + 0.4, 0.2, 1.3);
|
||||
g.happiness = clamp(g.happiness + gain, 0, 100);
|
||||
if (verdict === 'great') addThought(g, 'great_ride', { r: ride.name });
|
||||
else if (verdict === 'good') addThought(g, 'good_ride', { r: ride.name });
|
||||
else if (verdict === 'meh') addThought(g, 'meh_ride', { r: ride.name });
|
||||
else addThought(g, 'scary', { r: ride.name });
|
||||
if (verdict !== 'bad') g.favRide = ride.type;
|
||||
// nausea side effects
|
||||
if (ride.nausea > 3.5 && rng() < ride.nausea / 26) {
|
||||
const tx = Math.round(g.x), ty = Math.round(g.y);
|
||||
if (state.map.inBounds(tx, ty)) state.map.vomit[state.map.idx(tx, ty)] = 1;
|
||||
g.happiness = clamp(g.happiness - 8, 0, 100);
|
||||
}
|
||||
// litter from food items bought earlier
|
||||
if (rng() < 0.25) dropLitter(state, g);
|
||||
// walk out of the ride exit area before deciding
|
||||
g.state = 'walking';
|
||||
wanderTo(state, g, 3);
|
||||
}
|
||||
|
||||
function completePurchase(state, g) {
|
||||
const shop = state.shops.find(s => s.id === g.target?.id);
|
||||
g.state = 'walking';
|
||||
if (!shop) { decide(state, g); return; }
|
||||
const fortuneMul = state.spells.active.fortune_rain ? 1.6 : 1;
|
||||
const price = Math.round(shop.price * g.spendMul * fortuneMul);
|
||||
if (g.money >= price) {
|
||||
g.money -= price; shop.stock--; shop.sold++; shop.income += price;
|
||||
earn(state, price, 'shopSales');
|
||||
const def = shop.def;
|
||||
if (def.need === 'hunger') g.hunger = Math.max(0, g.hunger - def.needFix);
|
||||
if (def.need === 'thirst') g.thirst = Math.max(0, g.thirst - def.needFix);
|
||||
if (def.need === 'toilet') g.toilet = Math.max(0, g.toilet - def.needFix);
|
||||
if (def.need === 'health') g.happiness = clamp(g.happiness + 10, 0, 100);
|
||||
if (def.happyBoost) g.happiness = clamp(g.happiness + def.happyBoost, 0, 100);
|
||||
g.happiness = clamp(g.happiness + 4, 0, 100);
|
||||
if (def.need === 'hunger' || def.need === 'thirst') {
|
||||
// carrying food/drink may become litter
|
||||
g.carryingFood = true;
|
||||
}
|
||||
if (def.need === 'toilet' && rng() < 0.02) {/* nothing */ }
|
||||
} else if (rng() < 0.3) addThought(g, 'broke');
|
||||
decide(state, g);
|
||||
}
|
||||
|
||||
export function dropLitter(state, g) {
|
||||
const tx = Math.round(g.x), ty = Math.round(g.y);
|
||||
if (!state.map.inBounds(tx, ty)) return;
|
||||
// bins nearby prevent litter
|
||||
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
|
||||
const o = state.map.getObject(tx + dx, ty + dy);
|
||||
if (o && o.kind === 'scenery') {
|
||||
const sc = state.sceneryList.find(s => s.id === o.id);
|
||||
if (sc && sc.def.antiLitter) return;
|
||||
}
|
||||
}
|
||||
const i = state.map.idx(tx, ty);
|
||||
if (state.map.pathType[i]) state.map.litter[i] = clamp(state.map.litter[i] + 0.6, 0, 1.5);
|
||||
g.carryingFood = false;
|
||||
}
|
||||
|
||||
// ---------------- decisions ----------------
|
||||
function decide(state, g) {
|
||||
// urgent needs first
|
||||
if (g.toilet > 82) { if (tryGoShop(state, g, s => s.type === 'toilet')) return; }
|
||||
if (g.hunger > 78) { if (tryGoShop(state, g, s => s.type === 'food')) { return; } }
|
||||
if (g.thirst > 74) { if (tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return; }
|
||||
if (g.energy < 22) {
|
||||
if (tryGoBench(state, g)) return;
|
||||
if (rng() < 0.5) { decideLeave(state, g); return; }
|
||||
}
|
||||
// ride?
|
||||
if (g.rideCd <= 0) {
|
||||
const ride = pickRide(state, g);
|
||||
if (ride) { goTarget(state, g, { kind: 'ride', id: ride.id }, ride.entranceX, ride.entranceY, ride); return; }
|
||||
}
|
||||
// casual shopping
|
||||
if (rng() < 0.3 && tryGoShop(state, g, s => s.type === 'souvenir' || s.type === 'balloon')) return;
|
||||
// satisfy needs opportunistically
|
||||
if (g.hunger > 50 && tryGoShop(state, g, s => s.type === 'food')) return;
|
||||
if (g.thirst > 48 && tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return;
|
||||
if (g.toilet > 55 && tryGoShop(state, g, s => s.type === 'toilet')) return;
|
||||
// wander
|
||||
wanderTo(state, g, 8 + Math.floor(rng() * 8));
|
||||
}
|
||||
|
||||
function pickRide(state, g) {
|
||||
const cands = state.rides.filter(r => r.status === 'open' && r.queue.length < 24);
|
||||
if (!cands.length) return null;
|
||||
// score: intensity preference match + closeness + low queue
|
||||
let best = null, bestScore = -1;
|
||||
for (const r of cands) {
|
||||
const fit = 1 - Math.abs((r.intensity || 0) / 10 - g.prefThrill) * 2;
|
||||
if (fit < 0.05 && rng() < 0.8) continue; // wrong vibe usually skip
|
||||
const d = Math.hypot(r.entranceX - g.x, r.entranceY - g.y);
|
||||
const score = fit * 2 + Math.max(0, 1.2 - d / 40) + (r.queue.length < 6 ? 0.4 : 0) + rng() * 0.6
|
||||
- (r.price * g.spendMul > g.money ? 5 : 0)
|
||||
+ (r.type === g.favRide ? 0.8 : 0);
|
||||
if (score > bestScore) { bestScore = score; best = r; }
|
||||
}
|
||||
return bestScore > 0.3 ? best : null;
|
||||
}
|
||||
|
||||
function goTarget(state, g, target, tx, ty, rideObj) {
|
||||
const [sx, sy] = snapToPath(state.map, g.x, g.y);
|
||||
let ex = tx, eyy = ty;
|
||||
if (rideObj) {
|
||||
// aim for a path tile adjacent to ride entrance
|
||||
const adj = adjacentPath(state.map, rideObj.entranceX, rideObj.entranceY);
|
||||
if (adj) { ex = adj[0]; eyy = adj[1]; }
|
||||
else { wanderTo(state, g, 4); return false; }
|
||||
}
|
||||
const p = findPath(state.map, sx, sy, ex, eyy, 9000);
|
||||
if (!p) { wanderTo(state, g, 5); return false; }
|
||||
g.path = p; g.target = target; g.state = 'walking';
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryGoShop(state, g, pred) {
|
||||
const opts = state.shops.filter(s => pred(s) && !s.damaged && s.stock > 0);
|
||||
if (!opts.length) return false;
|
||||
opts.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y));
|
||||
for (let k = 0; k < Math.min(3, opts.length); k++) {
|
||||
const s = opts[k];
|
||||
const adj = adjacentPath(state.map, s.x, s.y);
|
||||
if (!adj) continue;
|
||||
if (goTarget(state, g, { kind: 'shop', id: s.id }, adj[0], adj[1])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tryGoBench(state, g) {
|
||||
const cands = state.sceneryList.filter(sc => sc.def.rest);
|
||||
if (!cands.length) return false;
|
||||
cands.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y));
|
||||
const b = cands[0];
|
||||
const adj = adjacentPath(state.map, b.x, b.y);
|
||||
if (!adj) return false;
|
||||
return goTarget(state, g, { kind: 'bench', id: b.id }, adj[0], adj[1]);
|
||||
}
|
||||
|
||||
export function wanderTo(state, g, r) {
|
||||
const [sx, sy] = snapToPath(state.map, g.x, g.y);
|
||||
const dest = randomNearbyPath(state.map, rng, sx, sy, 2, r);
|
||||
if (!dest) { /* isolated: stay */ g.path = []; return false; }
|
||||
const p = findPath(state.map, sx, sy, dest[0], dest[1], 3000);
|
||||
if (p) { g.path = p; g.target = { kind: 'wander' }; if (g.state !== 'queuing' && g.state !== 'riding' && g.state !== 'buying' && g.state !== 'resting') g.state = 'walking'; }
|
||||
return !!p;
|
||||
}
|
||||
|
||||
export function decideLeave(state, g) {
|
||||
const m = state.map;
|
||||
const gx = m.entranceX, gy = Math.min(m.size - 1, m.entranceY + 3);
|
||||
const [sx, sy] = snapToPath(m, g.x, g.y);
|
||||
const p = findPath(m, sx, sy, gx, gy, 12000);
|
||||
if (p) { g.path = p; g.state = 'leaving'; g.target = { kind: 'exit' }; }
|
||||
else { addThought(g, 'no_exit'); wanderTo(state, g, 6); }
|
||||
}
|
||||
|
||||
export function adjacentPath(map, x, y, prefer = null) {
|
||||
const dirs = prefer ? [[prefer[0], prefer[1]], ...[[1, 0], [0, 1], [-1, 0], [0, -1]].filter(d => d[0] !== prefer[0] || d[1] !== prefer[1])] : [[1, 0], [0, 1], [-1, 0], [0, -1]];
|
||||
for (const [dx, dy] of dirs) {
|
||||
if (map.isPath(x + dx, y + dy)) return [x + dx, y + dy];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// queue joining shared with rides module
|
||||
import { joinQueue } from './rides.js';
|
||||
|
||||
export function scareGuests(state, x, y, radius) {
|
||||
for (const g of state.guests) {
|
||||
if (g.state === 'riding' || g.state === 'queuing') continue;
|
||||
const d = Math.hypot(g.x - x, g.y - y);
|
||||
if (d < radius) {
|
||||
g.happiness = clamp(g.happiness - 14, 0, 100);
|
||||
addThought(g, 'monster');
|
||||
// flee away from x,y toward entrance-ish random direction
|
||||
const ang = Math.atan2(g.y - y, g.x - x) + (rng() - 0.5);
|
||||
const fx = Math.round(clamp(g.x + Math.cos(ang) * 8, 1, state.map.size - 2));
|
||||
const fy = Math.round(clamp(g.y + Math.sin(ang) * 8, 1, state.map.size - 2));
|
||||
const dest = nearestWalkableNear(state.map, fx, fy);
|
||||
if (dest) {
|
||||
const p = findPath(state.map, Math.round(g.x), Math.round(g.y), dest[0], dest[1], 2500);
|
||||
if (p) { g.path = p; g.fleeingT = 3 + rng() * 3; g.state = 'walking'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nearestWalkableNear(map, x, y) {
|
||||
for (let r = 0; r < 10; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
||||
if (map.isWalkable(x + dx, y + dy)) return [x + dx, y + dy];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
// ============ heroes.js — guild, heroes, monster invasions, battles ============
|
||||
import { HERO_CLASSES, MONSTER_TYPES, DIRS } from '../core/config.js';
|
||||
import { uid, clamp, choice } from '../core/util.js';
|
||||
import { earn } from './economy.js';
|
||||
import { scareGuests } from './guests.js';
|
||||
|
||||
const rng = Math.random;
|
||||
|
||||
// ---------------- Guild ----------------
|
||||
export function buildGuild(state, x, y) {
|
||||
const m = state.map;
|
||||
for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) {
|
||||
if (!m.isBuildable(x + xx, y + yy) || m.occupied(x + xx, y + yy)) return null;
|
||||
// must touch a path so heroes/guests can find it
|
||||
}
|
||||
const nearPath = [[x + 2, y], [x + 2, y + 1], [x - 1, y], [x - 1, y + 1], [x, y + 2], [x + 1, y + 2]]
|
||||
.some(c => m.isPath(c[0], c[1]));
|
||||
if (!nearPath) return null;
|
||||
const guild = { x, y, w: 2, h: 2, cap: 4 };
|
||||
state.guild = guild;
|
||||
for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++)
|
||||
m.setObject(x + xx, y + yy, { kind: 'guild', ox: xx, oy: yy });
|
||||
return guild;
|
||||
}
|
||||
|
||||
export function guildCap(state) {
|
||||
let cap = 4;
|
||||
if (state.research.unlocked.includes('guild2')) cap += 2;
|
||||
return cap;
|
||||
}
|
||||
|
||||
export function recruitHero(state, clsId) {
|
||||
const def = HERO_CLASSES[clsId];
|
||||
if (!def || !state.guild) return { error: 'Build the Heroes Guild first!' };
|
||||
if (state.heroes.length >= guildCap(state)) return { error: 'Guild roster is full.' };
|
||||
if (!clsUnlocked(state, clsId)) return { error: 'Not researched yet.' };
|
||||
if (state.cash < def.cost && !state.sandbox) return { error: 'Not enough gold.' };
|
||||
if (!state.sandbox) {
|
||||
payGold(state, def.cost);
|
||||
}
|
||||
const h = {
|
||||
id: uid(), kind: 'hero', cls: clsId, def,
|
||||
name: randomHeroName(clsId),
|
||||
x: state.guild.x + 0.5 + rng(), y: state.guild.y + 1.7,
|
||||
hp: def.hp, maxHp: def.hp,
|
||||
lvl: 1, xp: 0, xpNext: 40,
|
||||
gear: 0, // gear tiers bought
|
||||
atkCd: 0, path: [], targetId: null,
|
||||
revivingT: 0, alive: true, kills: 0,
|
||||
speed: def.speed,
|
||||
};
|
||||
state.heroes.push(h);
|
||||
state.toasts.push({ kind: 'good', title: `${h.name} joins the guild!`, text: def.name + ' ready for battle.' });
|
||||
return { ok: true, hero: h };
|
||||
}
|
||||
function payGold(state, amt) {
|
||||
if (state.sandbox) return;
|
||||
state.cash -= amt;
|
||||
state.finance.current['heroes'] = (state.finance.current['heroes'] || 0) - amt;
|
||||
}
|
||||
function payGoldRaw(state, amt) {
|
||||
state.cash -= amt;
|
||||
state.finance.current['heroes'] = (state.finance.current['heroes'] || 0) - amt;
|
||||
}
|
||||
export function buyGear(state, hero) {
|
||||
const tiers = [400, 900, 1600];
|
||||
if (hero.gear >= 3) return { error: 'Fully geared!' };
|
||||
const cost = tiers[hero.gear];
|
||||
if (!state.sandbox && state.cash < cost) return { error: 'Not enough gold.' };
|
||||
payGoldRaw(state, cost);
|
||||
hero.gear++;
|
||||
hero.maxHp = Math.round(hero.maxHp * 1.18);
|
||||
hero.hp = hero.maxHp;
|
||||
state.toasts.push({ kind: 'gold', title: `${hero.name} upgraded!`, text: `Gear tier ${hero.gear} equipped.` });
|
||||
return { ok: true };
|
||||
}
|
||||
export function clsUnlocked(state, clsId) {
|
||||
const def = HERO_CLASSES[clsId];
|
||||
if (def.tier === 0 || state.sandbox) return true;
|
||||
if (def.id === 'cleric') return state.research.unlocked.includes('cleric');
|
||||
if (def.id === 'paladin') return state.research.unlocked.includes('paladin');
|
||||
return false;
|
||||
}
|
||||
const FIRST = ['Aldric','Bryn','Cedric','Dara','Elric','Faela','Gareth','Hilda','Ivar','Jora','Kael','Lyra','Merrick','Nyx','Orin','Perrin','Rowan','Sable','Torvald','Ulric','Vera','Wulfric','Ysolde','Zephyr'];
|
||||
function randomHeroName() {
|
||||
return choice(rng, FIRST) + ' the ' + choice(rng, ['Brave','Bold','Grim','Swift','Bright','Stalwart','Valiant','Wise']);
|
||||
}
|
||||
|
||||
// ---------------- Invasions ----------------
|
||||
export function maybeStartInvasion(state) {
|
||||
if (!state.pendingInvasion) return;
|
||||
state.pendingInvasion = false;
|
||||
if (state.spells.active.warding_sigil) {
|
||||
state.toasts.push({ kind: 'magic', title: 'Warding Sigil holds!', text: 'The rift falters — invasion repelled by magic.' });
|
||||
return;
|
||||
}
|
||||
spawnWave(state);
|
||||
}
|
||||
|
||||
function edgeSpawnPoint(map) {
|
||||
for (let tries = 0; tries < 80; tries++) {
|
||||
const side = Math.floor(rng() * 4);
|
||||
let x, y;
|
||||
if (side === 0) { x = 1 + Math.floor(rng() * (map.size - 2)); y = 1; }
|
||||
else if (side === 1) { x = 1 + Math.floor(rng() * (map.size - 2)); y = map.size - 2; }
|
||||
else if (side === 2) { x = 1; y = 1 + Math.floor(rng() * (map.size - 2)); }
|
||||
else { x = map.size - 2; y = 1 + Math.floor(rng() * (map.size - 2)); }
|
||||
if (map.terrainAt(x, y) !== 3 && !map.occupied(x, y)) return [x + 0.5, y + 0.5];
|
||||
}
|
||||
return [map.entranceX, map.size - 2];
|
||||
}
|
||||
|
||||
export function spawnWave(state) {
|
||||
const scen = require_scen(state);
|
||||
state.invasion.waveActive = true;
|
||||
state.invasion.count++;
|
||||
const scale = scen?.invasionScale || 1;
|
||||
const yearF = 1 + (state.time.year - 1) * 0.35;
|
||||
let count = Math.max(2, Math.round((2.5 + state.time.year * scale) * yearF * 0.8));
|
||||
const types = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = rng();
|
||||
if (r < 0.35) types.push('slime');
|
||||
else if (r < 0.7) types.push('goblin');
|
||||
else if (r < 0.9) types.push('wolf');
|
||||
else types.push('brute');
|
||||
}
|
||||
if (scen?.bossAt && state.invasion.count % scen.bossAt === 0) types.push('boss');
|
||||
for (const t of types) {
|
||||
const def = MONSTER_TYPES[t];
|
||||
const [x, y] = edgeSpawnPoint(state.map);
|
||||
state.monsters.push({
|
||||
id: uid(), kind: 'monster', type: t, def,
|
||||
x, y, hp: def.hp, maxHp: def.hp,
|
||||
atkCd: rng() * 1.2, speed: def.speed,
|
||||
slowT: 0, flashT: 0,
|
||||
});
|
||||
}
|
||||
state.toasts.push({ kind: 'bad', title: '⚔️ INVASION!', text: `${types.length} monsters pour into your park! Your heroes will fight.` });
|
||||
}
|
||||
function require_scen(state) {
|
||||
// lazy import avoidance: scenario config passed via state cache
|
||||
return state._scenCfg || null;
|
||||
}
|
||||
export function cacheScenario(state, scen) { state._scenCfg = scen; }
|
||||
|
||||
// ---------------- per-frame updates ----------------
|
||||
export function updateBattles(state, dt) {
|
||||
maybeStartInvasion(state);
|
||||
const warding = !!state.spells.active.warding_sigil;
|
||||
|
||||
// --- monsters ---
|
||||
for (let i = state.monsters.length - 1; i >= 0; i--) {
|
||||
const mo = state.monsters[i];
|
||||
let tgtObjLocal = undefined;
|
||||
if (mo.flashT > 0) mo.flashT -= dt;
|
||||
if (warding) {
|
||||
mo.hp -= dt * 8;
|
||||
// flee to nearest edge
|
||||
const ex = mo.x < state.map.size / 2 ? 0.5 : state.map.size - 0.5;
|
||||
const ey = mo.y < state.map.size / 2 ? 0.5 : state.map.size - 0.5;
|
||||
steer(mo, ex, ey, dt * 1.3, state.map);
|
||||
if (mo.x <= 1 || mo.y <= 1 || mo.x >= state.map.size - 1 || mo.y >= state.map.size - 1) {
|
||||
state.monsters.splice(i, 1); continue;
|
||||
}
|
||||
if (mo.hp <= 0) killMonster(state, i, null);
|
||||
continue;
|
||||
}
|
||||
// pick victim: nearest guest within aggro, else nearest building
|
||||
let tx = null, ty = null, mode = null, bestD = Infinity;
|
||||
for (const g of state.guests) {
|
||||
if (g.state === 'riding') continue;
|
||||
const d = Math.hypot(g.x - mo.x, g.y - mo.y);
|
||||
if (d < 9 && d < bestD) { bestD = d; tx = g.x; ty = g.y; mode = 'guest'; }
|
||||
}
|
||||
if (mode === null) {
|
||||
let bestShop = null, bsD = Infinity;
|
||||
for (const s of [...state.shops, ...state.rides]) {
|
||||
const d = Math.hypot((s.x + 0.5) - mo.x, (s.y + 0.5) - mo.y);
|
||||
if (d < bsD) { bsD = d; bestShop = s; }
|
||||
}
|
||||
if (bestShop) {
|
||||
tx = bestShop.x + 0.5; ty = bestShop.y + 0.5; mode = 'building'; tgtObjLocal = bestShop;
|
||||
}
|
||||
}
|
||||
if (tx !== null) steer(mo, tx, ty, dt, state.map);
|
||||
mo.atkCd -= dt;
|
||||
if (mode === 'guest' && bestD < 0.8 && mo.atkCd <= 0) {
|
||||
mo.atkCd = 1.2;
|
||||
scareGuests(state, mo.x, mo.y, 3);
|
||||
for (const g of state.guests) {
|
||||
if (Math.hypot(g.x - mo.x, g.y - mo.y) < 1.2) {
|
||||
g.happiness = clamp(g.happiness - 16, 0, 100);
|
||||
g.money = Math.max(0, g.money - Math.floor(rng() * 15));
|
||||
}
|
||||
}
|
||||
addFloat(state, mo.x, mo.y - 0.6, 'RAWR!', '#ff6b6b');
|
||||
} else if (mode === 'building' && tgtObjLocal) {
|
||||
const d = Math.hypot(tgtObjLocal.x + 0.5 - mo.x, tgtObjLocal.y + 0.5 - mo.y);
|
||||
if (d < 1.4 && mo.atkCd <= 0) {
|
||||
mo.atkCd = 1.4;
|
||||
tgtObjLocal.damaged = Math.min(1.01, (tgtObjLocal.damaged || 0) + 0.34);
|
||||
addFloat(state, tgtObjLocal.x + 0.5, tgtObjLocal.y, 'SMASH', '#ff9d76');
|
||||
if (tgtObjLocal.damaged > 1 && tgtObjLocal.def) {
|
||||
state.vandalism = Math.min(20, state.vandalism + 2);
|
||||
state.toasts.push({ kind: 'bad', title: `${tgtObjLocal.def.name} wrecked!`, text: 'It needs repairs before it can serve guests again.' });
|
||||
const selfIdx = state.monsters.indexOf(mo);
|
||||
if (selfIdx >= 0) killMonster(state, selfIdx, null, true);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mo.hp <= 0) killMonster(state, i, null);
|
||||
}
|
||||
|
||||
// --- heroes ---
|
||||
for (const h of state.heroes) {
|
||||
if (!h.alive) {
|
||||
h.revivingT -= dt;
|
||||
if (h.revivingT <= 0) {
|
||||
h.alive = true; h.hp = Math.round(h.maxHp * 0.6);
|
||||
h.x = state.guild.x + 0.5; h.y = state.guild.y + 1.7;
|
||||
state.toasts.push({ kind: 'good', title: `${h.name} revived`, text: 'Back from the healing temple.' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
h.atkCd -= dt;
|
||||
// regen slowly
|
||||
h.hp = clamp(h.hp + dt * 0.8, 0, h.maxHp);
|
||||
// find target
|
||||
let target = null, bd = Infinity;
|
||||
for (const mo of state.monsters) {
|
||||
const d = Math.hypot(mo.x - h.x, mo.y - h.y);
|
||||
if (d < bd) { bd = d; target = mo; }
|
||||
}
|
||||
const engageRange = h.def.range;
|
||||
if (target && bd < 14 + engageRange) {
|
||||
if (bd > engageRange) {
|
||||
steer(h, target.x, target.y, dt, state.map);
|
||||
} else if (h.atkCd <= 0) {
|
||||
h.atkCd = h.def.atkCd;
|
||||
heroAttack(state, h, target);
|
||||
}
|
||||
} else {
|
||||
patrolHero(state, h, dt);
|
||||
}
|
||||
}
|
||||
|
||||
// --- wave resolution ---
|
||||
if (state.invasion.waveActive && state.monsters.length === 0) {
|
||||
state.invasion.waveActive = false;
|
||||
state.invasion.repelled++;
|
||||
const reward = 150 * (require_scen(state)?.invasionScale || 1);
|
||||
earn(state, Math.round(reward), 'loot');
|
||||
state.mana = clamp(state.mana + 15, 0, state.manaMax);
|
||||
state.toasts.push({ kind: 'gold', title: 'Invasion repelled!', text: `The crowd cheers! Reward $${Math.round(reward)}.` });
|
||||
}
|
||||
|
||||
// auto-repair broken buildings over time
|
||||
for (const s of state.shops) if (s.damaged > 0) s.damaged = Math.max(0, s.damaged - dt / 90);
|
||||
for (const r of state.rides) if (r.damaged > 0) r.damaged = Math.max(0, r.damaged - dt / 120);
|
||||
}
|
||||
|
||||
function heroAttack(state, h, target) {
|
||||
const gearMul = 1 + h.gear * 0.25 + (state.research.unlocked.includes('gear2') ? 0.25 : 0);
|
||||
const dmg = h.def.dmg * (1 + (h.lvl - 1) * 0.1) * gearMul;
|
||||
const targets = [];
|
||||
if (h.def.aoe) {
|
||||
for (const mo of state.monsters) if (Math.hypot(mo.x - target.x, mo.y - target.y) <= h.def.aoe + 0.4) targets.push(mo);
|
||||
} else targets.push(target);
|
||||
for (const mo of targets) {
|
||||
mo.hp -= dmg;
|
||||
mo.flashT = 0.18;
|
||||
}
|
||||
state.effects.push({ kind: 'hit', x: target.x, y: target.y, t: 0, dur: 0.3, color: h.cls === 'mage' ? '#a86bff' : '#ffd166', aoe: !!h.def.aoe });
|
||||
addFloat(state, target.x, target.y - 0.5, String(Math.round(dmg)), h.cls === 'mage' ? '#c39bff' : '#ffe08a');
|
||||
// cleric heal pulse instead
|
||||
if (h.def.heal) {
|
||||
for (const ally of state.heroes) {
|
||||
if (!ally.alive || ally === h) continue;
|
||||
if (Math.hypot(ally.x - h.x, ally.y - h.y) < h.def.range + 1) {
|
||||
ally.hp = clamp(ally.hp + h.def.heal, 0, ally.maxHp);
|
||||
state.effects.push({ kind: 'heal', x: ally.x, y: ally.y, t: 0, dur: 0.4 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function killMonster(state, idx, killerHero, silent = false) {
|
||||
const mo = state.monsters[idx];
|
||||
if (!mo) return;
|
||||
state.monsters.splice(idx, 1);
|
||||
if (silent) return;
|
||||
earn(state, mo.def.gold, 'loot');
|
||||
state.heroStats.kills++;
|
||||
state.heroStats.lootGold += mo.def.gold;
|
||||
state.mana = clamp(state.mana + 2, 0, state.manaMax);
|
||||
if (mo.type === 'boss') state.invasion.bossKilled = true;
|
||||
state.effects.push({ kind: 'poof', x: mo.x, y: mo.y, t: 0, dur: 0.5, icon: mo.def.icon });
|
||||
// xp share
|
||||
for (const h of state.heroes) {
|
||||
if (!h.alive) continue;
|
||||
if (Math.hypot(h.x - mo.x, h.y - mo.y) < 11) {
|
||||
h.kills++;
|
||||
h.xp += mo.def.xp;
|
||||
while (h.xp >= h.xpNext) {
|
||||
h.xp -= h.xpNext;
|
||||
h.lvl++;
|
||||
h.xpNext = Math.round(h.xpNext * 1.5);
|
||||
h.maxHp = Math.round(h.maxHp * 1.15);
|
||||
h.hp = h.maxHp;
|
||||
addFloat(state, h.x, h.y - 1, 'LEVEL UP!', '#57d97a');
|
||||
state.toasts.push({ kind: 'good', title: `${h.name} reached level ${h.lvl}!`, text: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patrolHero(state, h, dt) {
|
||||
if (!h._patrol || Math.hypot(h._patrol[0] - h.x, h._patrol[1] - h.y) < 0.5 || rng() < dt * 0.1) {
|
||||
const gx = state.guild ? state.guild.x : h.x;
|
||||
const gy = state.guild ? state.guild.y : h.y;
|
||||
h._patrol = [
|
||||
clamp(gx + (rng() - 0.5) * 24, 2, state.map.size - 3),
|
||||
clamp(gy + (rng() - 0.5) * 24, 2, state.map.size - 3),
|
||||
];
|
||||
}
|
||||
steer(h, h._patrol[0], h._patrol[1], dt * 0.7, state.map);
|
||||
}
|
||||
|
||||
/** simple steering with water avoidance */
|
||||
function steer(e, tx, ty, dt, map) {
|
||||
const dx = tx - e.x, dy = ty - e.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < 0.05) return;
|
||||
let nx = e.x + dx / d * e.speed * dt;
|
||||
let ny = e.y + dy / d * e.speed * dt;
|
||||
if (map.terrainAt(Math.floor(nx), Math.floor(ny)) === 3) {
|
||||
// try sliding around water
|
||||
const px = -dy / d, py = dx / d;
|
||||
nx = e.x + (dx / d * 0.5 + px) ;
|
||||
ny = e.y + (dy / d * 0.5 + py);
|
||||
const l = Math.hypot(nx - e.x, ny - e.y) || 1;
|
||||
nx = e.x + (nx - e.x) / l * e.speed * dt;
|
||||
ny = e.y + (ny - e.y) / l * e.speed * dt;
|
||||
if (map.terrainAt(Math.floor(nx), Math.floor(ny)) === 3) return; // stuck this frame
|
||||
}
|
||||
e.x = clamp(nx, 0.2, map.size - 0.2);
|
||||
e.y = clamp(ny, 0.2, map.size - 0.2);
|
||||
}
|
||||
|
||||
function addFloat(state, x, y, text, color) {
|
||||
state.floatTexts.push({ x, y, text, color, t: 0, dur: 1.1 });
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// ============ magic.js — spell casting system ============
|
||||
import { SPELLS } from '../core/config.js';
|
||||
import { clamp } from '../core/util.js';
|
||||
|
||||
export function spellUnlocked(state, id) {
|
||||
const def = SPELLS[id];
|
||||
if (!def) return false;
|
||||
if (state.sandbox || def.tier === 0) return true;
|
||||
return state.research.unlocked.includes(id);
|
||||
}
|
||||
|
||||
export function canCast(state, id) {
|
||||
const def = SPELLS[id];
|
||||
if (!spellUnlocked(state, id)) return { ok: false, why: 'Not researched' };
|
||||
if ((state.spells.cds[id] || 0) > 0) return { ok: false, why: 'Recharging' };
|
||||
if (state.mana < def.mana) return { ok: false, why: 'Not enough mana' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function castSpell(state, id) {
|
||||
const chk = canCast(state, id);
|
||||
if (!chk.ok) return chk;
|
||||
const def = SPELLS[id];
|
||||
state.mana -= def.mana;
|
||||
state.spells.cds[id] = def.cd;
|
||||
switch (id) {
|
||||
case 'sunburst':
|
||||
state.spells.active.sunburst = 2; // brief force-sunny
|
||||
state.weather.cur = 'sunny';
|
||||
burst(state, '☀️');
|
||||
break;
|
||||
case 'joy_aura':
|
||||
state.spells.active.joy_aura = def.dur;
|
||||
burst(state, '😊');
|
||||
break;
|
||||
case 'healing_light':
|
||||
for (const h of state.heroes) if (h.alive) { h.hp = h.maxHp; state.effects.push({ kind: 'heal', x: h.x, y: h.y, t: 0, dur: 0.8 }); }
|
||||
burst(state, '💚');
|
||||
break;
|
||||
case 'fortune_rain':
|
||||
state.spells.active.fortune_rain = def.dur;
|
||||
burst(state, '💸');
|
||||
break;
|
||||
case 'swift_build':
|
||||
state.spells.active.swift_build = def.dur;
|
||||
burst(state, '⚡');
|
||||
break;
|
||||
case 'monster_bane': {
|
||||
for (const mo of [...state.monsters]) mo.hp -= 70;
|
||||
for (let i = state.monsters.length - 1; i >= 0; i--) {
|
||||
if (state.monsters[i].hp <= 0) { /* deaths handled by battle loop */ }
|
||||
}
|
||||
burst(state, '💥');
|
||||
state.toasts.push({ kind: 'magic', title: 'Monster Bane!', text: 'Arcane fire rains on the invaders.' });
|
||||
break;
|
||||
}
|
||||
case 'warding_sigil':
|
||||
state.spells.active.warding_sigil = def.dur;
|
||||
burst(state, '🛡️');
|
||||
state.toasts.push({ kind: 'magic', title: 'Warding Sigil raised', text: 'Monsters flee; invasions blocked while active.' });
|
||||
break;
|
||||
case 'transmute': {
|
||||
transmuteGold(state);
|
||||
burst(state, '🪙');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function transmuteGold(state) {
|
||||
state.cash += 900;
|
||||
state.finance.current['misc'] = (state.finance.current['misc'] || 0) + 900;
|
||||
state.toasts.push({ kind: 'gold', title: 'Transmutation!', text: '+$900 conjured from the arcane ether.' });
|
||||
}
|
||||
|
||||
function burst(state, icon) {
|
||||
state.effects.push({ kind: 'spellburst', icon, t: 0, dur: 1.4 });
|
||||
}
|
||||
|
||||
export function tickSpells(state, dt) {
|
||||
// cooldowns
|
||||
for (const k of Object.keys(state.spells.cds)) {
|
||||
if (state.spells.cds[k] > 0) state.spells.cds[k] -= dt;
|
||||
}
|
||||
// durations
|
||||
for (const k of Object.keys(state.spells.active)) {
|
||||
state.spells.active[k] -= dt;
|
||||
if (state.spells.active[k] <= 0) delete state.spells.active[k];
|
||||
}
|
||||
// mana regen
|
||||
if (state.manaRegen === undefined) state.manaRegen = 0.4;
|
||||
state.mana = clamp(state.mana + state.manaRegen * dt, 0, state.manaMax);
|
||||
}
|
||||
|
||||
/** construction discount multiplier from spells */
|
||||
export function buildDiscount(state) {
|
||||
return state.spells.active.swift_build ? 0.5 : 1;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// ============ research.js — unlock tree ============
|
||||
import { UNLOCKS, RESEARCH_TRACKS } from '../core/config.js';
|
||||
|
||||
export function isUnlocked(state, key) {
|
||||
if (state.sandbox) return true;
|
||||
return state.research.unlocked.includes(key);
|
||||
}
|
||||
|
||||
export function canBuy(state, u) {
|
||||
if (isUnlocked(state, u.key)) return { ok: false, why: 'owned' };
|
||||
if (state.research.rp >= u.rp) return { ok: true };
|
||||
return { ok: false, why: 'rp' };
|
||||
}
|
||||
|
||||
export function buyUnlock(state, key) {
|
||||
const u = UNLOCKS.find(u => u.key === key);
|
||||
if (!u) return false;
|
||||
const chk = canBuy(state, u);
|
||||
if (!chk.ok) return false;
|
||||
state.research.rp -= u.rp;
|
||||
state.research.spentTotal += u.rp;
|
||||
state.research.unlocked.push(key);
|
||||
state.toasts.push({ kind: 'good', title: 'Research complete!', text: `${u.label} is now available to build.` });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unlocksByTrack() {
|
||||
const out = {};
|
||||
for (const t of Object.keys(RESEARCH_TRACKS)) out[t] = [];
|
||||
for (const u of UNLOCKS) (out[u.track] || (out[u.track] = [])).push(u);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// ============ rides.js — ride lifecycle, queues, cycles, breakdowns ============
|
||||
import { earn } from './economy.js';
|
||||
import { uid, clamp } from '../core/util.js';
|
||||
import { finishRide } from './guests.js';
|
||||
|
||||
const rng = Math.random;
|
||||
|
||||
export function joinQueue(state, g, ride) {
|
||||
const cost = Math.round(ride.price * g.spendMul);
|
||||
if (cost > g.money) {
|
||||
// can't afford — try later
|
||||
g.rideCd = 10 + rng() * 8;
|
||||
g.state = 'walking';
|
||||
return false;
|
||||
}
|
||||
if (ride.queue.length >= 30) {
|
||||
g.rideCd = 6;
|
||||
g.state = 'walking';
|
||||
return false;
|
||||
}
|
||||
ride.queue.push(g.id);
|
||||
g.state = 'queuing';
|
||||
// visual slot placement
|
||||
const i = ride.queue.length - 1;
|
||||
const row = Math.floor(i / 4), col = i % 4;
|
||||
g.x = ride.entranceX + (col - 1.5) * 0.45;
|
||||
g.y = ride.entranceY + 0.9 + row * 0.55;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateRides(state, dt) {
|
||||
for (const r of state.rides) {
|
||||
r.animPhase += dt;
|
||||
switch (r.status) {
|
||||
case 'open': stepOpenRide(state, r, dt); break;
|
||||
case 'testing': stepTestingRide(state, r, dt); break;
|
||||
case 'broken': break; // waits for mechanic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stepOpenRide(state, r, dt) {
|
||||
// random breakdown
|
||||
const hazard = 0.0022 * (1.06 - r.reliability) * (1 + (r.intensity || 2) / 8) * (r.isCustomCoaster ? 1.5 : 1);
|
||||
if (rng() < dt * hazard) { breakDown(state, r); return; }
|
||||
// board guests
|
||||
const cap = capacityOf(r);
|
||||
while (r.riders.length < cap && r.queue.length) {
|
||||
const gid = r.queue.shift();
|
||||
const g = state.guests.find(g => g.id === gid);
|
||||
if (!g) continue;
|
||||
const cost = Math.round(r.price * (g.spendMul || 1));
|
||||
if ((g.money ?? 0) < cost) {
|
||||
g.state = 'walking'; g.happiness = clamp(g.happiness - 3, 0, 100);
|
||||
continue;
|
||||
}
|
||||
g.money -= cost;
|
||||
earn(state, cost, 'rideTickets');
|
||||
r.income += cost;
|
||||
g.state = 'riding';
|
||||
r.riders.push(gid);
|
||||
}
|
||||
// run cycle
|
||||
if (r.riders.length > 0 || r.cycleT > 0) {
|
||||
r.cycleT += dt;
|
||||
advanceTrainIfCoaster(state, r);
|
||||
if (r.cycleT >= r.cycleDur) {
|
||||
unloadRide(state, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stepTestingRide(state, r, dt) {
|
||||
r.cycleT += dt;
|
||||
advanceTrainIfCoaster(state, r);
|
||||
if (r.cycleT >= r.cycleDur) {
|
||||
r.status = 'closed';
|
||||
r.cycleT = 0;
|
||||
resetTrain(r);
|
||||
state.toasts.push({ kind: 'good', title: `${r.name} tested OK`, text: 'You can now open it to guests.' });
|
||||
}
|
||||
}
|
||||
|
||||
function capacityOf(r) {
|
||||
if (!r.isCustomCoaster) return r.def.capacity;
|
||||
return Math.max(4, Math.min(r.def.capacity, Math.floor(trackLength(r) / 12) + 4));
|
||||
}
|
||||
|
||||
function unloadRide(state, r) {
|
||||
for (const gid of r.riders) {
|
||||
const g = state.guests.find(g => g.id === gid);
|
||||
if (!g) continue;
|
||||
const fit = 1 - Math.abs((r.intensity || 2) / 10 - (g.prefThrill ?? 0.5)) * 2;
|
||||
let verdict;
|
||||
const score = (r.excite || 1) * clamp(fit + 0.35, 0.15, 1.25);
|
||||
if ((r.intensity - (g.prefThrill * 10)) > 5.5) verdict = 'bad';
|
||||
else if (score > 5.5) verdict = 'great';
|
||||
else if (score > 3) verdict = 'good';
|
||||
else verdict = 'meh';
|
||||
g.state = 'walking';
|
||||
finishRide(state, g, r, verdict);
|
||||
}
|
||||
r.totalRiders += r.riders.length;
|
||||
r.riders = [];
|
||||
r.cycleT = 0;
|
||||
resetTrain(r);
|
||||
// pull next waiting guests toward queue head
|
||||
reflowQueue(state, r);
|
||||
}
|
||||
|
||||
function reflowQueue(state, r) {
|
||||
r.queue.forEach((gid, i) => {
|
||||
const g = state.guests.find(g => g.id === gid);
|
||||
if (!g) return;
|
||||
const row = Math.floor(i / 4), col = i % 4;
|
||||
g.x = r.entranceX + (col - 1.5) * 0.45;
|
||||
g.y = r.entranceY + 0.9 + row * 0.55;
|
||||
});
|
||||
}
|
||||
|
||||
export function breakDown(state, r) {
|
||||
r.status = 'broken';
|
||||
r.breakdownT = 24 + rng() * 26;
|
||||
r.brokenCount++;
|
||||
// scare riders off
|
||||
for (const gid of r.riders) {
|
||||
const g = state.guests.find(g => g.id === gid);
|
||||
if (g) { g.state = 'walking'; g.happiness = clamp(g.happiness - 18, 0, 100); }
|
||||
}
|
||||
r.riders = [];
|
||||
r.queue.forEach(gid => {
|
||||
const g = state.guests.find(g => g.id === gid);
|
||||
if (g) { g.state = 'walking'; g.rideCd = 15; }
|
||||
});
|
||||
r.queue = [];
|
||||
state.toasts.push({ kind: 'bad', title: `${r.name} broke down!`, text: 'Send a mechanic!' });
|
||||
}
|
||||
|
||||
export function setRideOpen(state, r, open) {
|
||||
if (open) {
|
||||
if (r.status === 'broken') return false;
|
||||
r.status = 'open';
|
||||
r.cycleT = 0;
|
||||
} else {
|
||||
r.status = 'closed';
|
||||
r.queue.forEach(gid => {
|
||||
const g = state.guests.find(g => g.id === gid);
|
||||
if (g) g.state = 'walking';
|
||||
});
|
||||
r.queue = [];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function startTest(state, r) {
|
||||
if (r.status === 'broken') return false;
|
||||
r.status = 'testing';
|
||||
r.cycleT = 0;
|
||||
resetTrain(r);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeRide(state, r) {
|
||||
// refund half construction value
|
||||
const refund = Math.round(r.def.cost * 0.5 * (r.isCustomCoaster ? 0.6 : 1));
|
||||
earn(state, refund, 'misc');
|
||||
for (let yy = 0; yy < r.h; yy++) for (let xx = 0; xx < r.w; xx++) {
|
||||
const o = state.map.getObject(r.x + xx, r.y + yy);
|
||||
if (o && o.kind === 'ride' && o.id === r.id) state.map.clearObject(r.x + xx, r.y + yy);
|
||||
}
|
||||
// free track cells
|
||||
if (r.track) {
|
||||
for (let i = 0; i < r.track.length; i++) {
|
||||
const t = r.track[i];
|
||||
const o = state.map.getObject(t.x, t.y);
|
||||
if (o && o.kind === 'track') state.map.clearObject(t.x, t.y);
|
||||
}
|
||||
}
|
||||
[...r.queue, ...r.riders].forEach(gid => {
|
||||
const g = state.guests.find(g => g.id === gid);
|
||||
if (g) g.state = 'walking';
|
||||
});
|
||||
state.rides = state.rides.filter(x => x !== r);
|
||||
return refund;
|
||||
}
|
||||
|
||||
// ---------------- custom coaster train animation ----------------
|
||||
export function trackLength(r) {
|
||||
return r.track ? r.track.length : 0;
|
||||
}
|
||||
function resetTrain(r) {
|
||||
if (r.train) { r.train.pos = 0; r.train.speed = 0; r.train.done = false; }
|
||||
}
|
||||
function advanceTrainIfCoaster(state, r) {
|
||||
if (!r.isCustomCoaster || !r.track?.length) return;
|
||||
const tr = r.train;
|
||||
if (!tr) return;
|
||||
const frac = r.cycleT / r.cycleDur;
|
||||
tr.progress = clamp(frac, 0, 1); // renderer interpolates along track
|
||||
}
|
||||
|
||||
/** Compute per-tick position of coaster train for smooth animation */
|
||||
export function trainPosition(r) {
|
||||
if (!r.track || !r.track.length) return null;
|
||||
const prog = r.train?.progress ?? 0;
|
||||
const f = prog * (r.track.length - 1);
|
||||
const i = Math.floor(f);
|
||||
const t = f - i;
|
||||
const a = r.track[Math.min(i, r.track.length - 1)];
|
||||
const b = r.track[Math.min(i + 1, r.track.length - 1)];
|
||||
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,
|
||||
loop: a.loop ? (t < 0.5 ? t * 2 : 2 - t * 2) : 0,
|
||||
dirIdx: i % r.track.length,
|
||||
piece: a,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// ============ save.js — localStorage slots, autosave, export/import ============
|
||||
import { serialize, deserialize } from '../game/state.js';
|
||||
import { download } from '../core/util.js';
|
||||
|
||||
const KEY = 'arcane_tycoon_save_';
|
||||
const SLOTS = ['auto', 'slot1', 'slot2', 'slot3'];
|
||||
|
||||
export function listSaves() {
|
||||
const out = [];
|
||||
for (const s of SLOTS) {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY + s);
|
||||
if (!raw) { out.push({ slot: s, exists: false }); continue; }
|
||||
const d = JSON.parse(raw);
|
||||
out.push({
|
||||
slot: s, exists: true,
|
||||
parkName: d.park?.name || 'Park',
|
||||
scenario: d.scenario,
|
||||
date: `Y${d.time?.year} M${(d.time?.month ?? 0) + 1}`,
|
||||
cash: d.cash,
|
||||
guests: d.guests?.length ?? 0,
|
||||
savedAt: d.savedAt,
|
||||
});
|
||||
} catch {
|
||||
out.push({ slot: s, exists: false });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function saveTo(state, slot) {
|
||||
const data = serialize(state);
|
||||
data.savedAt = Date.now();
|
||||
try {
|
||||
localStorage.setItem(KEY + slot, JSON.stringify(data));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('save failed', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadFrom(slot) {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY + slot);
|
||||
if (!raw) return null;
|
||||
return deserialize(JSON.parse(raw));
|
||||
} catch (e) {
|
||||
console.error('load failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAutosave() {
|
||||
try { return !!localStorage.getItem(KEY + 'auto'); } catch { return false; }
|
||||
}
|
||||
|
||||
export function autosave(state) {
|
||||
return saveTo(state, 'auto');
|
||||
}
|
||||
|
||||
export function exportSave(state) {
|
||||
const data = serialize(state);
|
||||
data.savedAt = Date.now();
|
||||
const name = (state.park.name || 'park').replace(/\W+/g, '_').toLowerCase();
|
||||
download(`arcane-tycoon-${name}.json`, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function importSaveText(text) {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (!data.version) throw new Error('Not an Arcane Tycoon save');
|
||||
return deserialize(data);
|
||||
} catch (e) {
|
||||
console.error('import failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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.' });
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// ============ state.js — central game state, time, spawning, objectives ============
|
||||
import { GameMap } from '../world/map.js';
|
||||
import { makeRng, uid, resetUid, clamp, choice } from '../core/util.js';
|
||||
import { SCENARIOS, WEATHER, RIDE_TYPES, SHOP_TYPES, SCENERY_TYPES, STAFF_TYPES, GUEST_NAMES, GUEST_COLORS, AWARDS_POOL, MAX_Z, UNLOCKS } from '../core/config.js';
|
||||
import { monthClose, tickCampaigns } from './economy.js';
|
||||
|
||||
export const HOUR_RATE = 24 / 480; // 1 in-game day = 480 real seconds at 1×
|
||||
export const DAYS_PER_MONTH = 8; // short months keep finance cycles lively
|
||||
export const GUEST_CAP = 320;
|
||||
|
||||
let S = null;
|
||||
export const getState = () => S;
|
||||
export function setState(s) { S = s; }
|
||||
|
||||
export function newGame(scenarioId) {
|
||||
const scen = SCENARIOS.find(s => s.id === scenarioId) || SCENARIOS[0];
|
||||
resetUid(1);
|
||||
const st = {
|
||||
version: 3,
|
||||
scenario: scen.id,
|
||||
sandbox: !!scen.sandbox,
|
||||
startedAt: Date.now(),
|
||||
rngSeed: (Math.random() * 0xffffffff) >>> 0,
|
||||
cash: scen.cash,
|
||||
loan: 0,
|
||||
loanLimit: scen.loanLimit,
|
||||
park: {
|
||||
name: pickParkName(scen),
|
||||
open: true,
|
||||
entranceFee: scen.id === 'meadows' ? 0 : 5,
|
||||
},
|
||||
time: { hour: 8.5, day: 1, month: 0, year: 1 },
|
||||
weather: { cur: 'sunny', timer: 90 + Math.random() * 120 },
|
||||
mana: 40, manaMax: 40,
|
||||
research: { rp: 0, spentTotal: 0, unlocked: [] },
|
||||
spells: { active: {}, cds: {} },
|
||||
campaigns: [],
|
||||
map: null,
|
||||
rides: [], shops: [], sceneryList: [], staff: [], guests: [], heroes: [], monsters: [],
|
||||
effects: [], // transient visual effects {kind,x,y,t,dur,...}
|
||||
floatTexts: [],
|
||||
guild: null, // {x,y,w,h,cap}
|
||||
heroStats: { kills: 0, lootGold: 0, losses: 0 },
|
||||
invasion: { nextMonthIdx: 0, waveActive: false, repelled: 0, bossKilled: false, count: 0 },
|
||||
stats: null,
|
||||
finance: { current: {}, history: [], lastMonthProfit: 0 },
|
||||
ratingHistory: [0],
|
||||
awards: [],
|
||||
vandalism: 0,
|
||||
objectivesDone: {},
|
||||
won: false, lost: false,
|
||||
toasts: [], // drained by UI
|
||||
guestSpawnAcc: 0,
|
||||
autosaveMonthCounter: 0,
|
||||
uiHintsSeen: {},
|
||||
};
|
||||
st.map = buildMap(scen);
|
||||
placeInitialLayout(st, scen);
|
||||
if (st.sandbox) unlockEverythingSync(st);
|
||||
st.invasion.nextMonthIdx = monthIndex(st) + scen.invasionStartMonth;
|
||||
recomputeManaCap(st);
|
||||
recomputeStats(st);
|
||||
setState(st);
|
||||
return st;
|
||||
}
|
||||
|
||||
function pickParkName(scen) {
|
||||
const names = ['Everdawn Park', 'Moonhollow Gardens', 'Silverbranch Park', 'Emberfall Kingdom', 'Starweald Gardens'];
|
||||
return names[Math.floor(Math.random() * names.length)];
|
||||
}
|
||||
|
||||
function buildMap(scen) {
|
||||
const m = new GameMap(scen.mapSize);
|
||||
m.generate(scen.gen, scen.mapSeed);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Entrance plaza: gate marker + initial paths + guild plot reserved */
|
||||
function placeInitialLayout(st, scen) {
|
||||
const m = st.map;
|
||||
const ex = m.entranceX, ey = m.entranceY;
|
||||
// clear & pave entrance corridor + plaza
|
||||
for (let y = ey - 2; y < Math.min(m.size, ey + 4); y++) {
|
||||
for (let x = ex - 2; x <= ex + 2; x++) {
|
||||
const i = m.idx(x, y);
|
||||
if (m.terrain[i] === 3) m.terrain[i] = 0; // dry any water near entrance
|
||||
m.pathType[i] = 1;
|
||||
}
|
||||
}
|
||||
// plaza square
|
||||
for (let y = ey - 6; y < ey - 2; y++) {
|
||||
for (let x = ex - 4; x <= ex + 4; x++) {
|
||||
if (!m.inBounds(x, y)) continue;
|
||||
const i = m.idx(x, y);
|
||||
if (m.terrain[i] === 3) m.terrain[i] = 0;
|
||||
m.pathType[i] = 1;
|
||||
}
|
||||
}
|
||||
// a couple of starter trees around plaza
|
||||
const rng = makeRng(scen.mapSeed ^ 777);
|
||||
let placedTrees = 0;
|
||||
for (let tries = 0; tries < 400 && placedTrees < 10; tries++) {
|
||||
const x = Math.floor(rng() * m.size), y = Math.floor(rng() * m.size);
|
||||
if (!m.isBuildable(x, y) || m.occupied(x, y)) continue;
|
||||
if (Math.abs(x - ex) < 6 && y > ey - 8) continue;
|
||||
addSceneryObj(st, 'tree_' + (rng() < .5 ? 'oak' : 'pine'), x, y, true);
|
||||
placedTrees++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- entity factories ----------------
|
||||
export function addRideObj(state, typeId, x, y, opts = {}) {
|
||||
const def = RIDE_TYPES[typeId];
|
||||
const ride = {
|
||||
id: uid(), type: typeId, def,
|
||||
name: opts.name || def.name,
|
||||
x, y, w: def.w, h: def.h,
|
||||
price: Math.round(def.excite * 0.8),
|
||||
status: 'closed', // closed | testing | open | broken
|
||||
queue: [], // guest ids waiting
|
||||
riders: [], // guest ids currently riding
|
||||
cycleT: 0, cycleDur: def.rideTime,
|
||||
breakdownT: 0, reliability: 0.92 + Math.random() * 0.06,
|
||||
totalRiders: 0, income: 0,
|
||||
animPhase: 0,
|
||||
excite: def.excite, intensity: def.intensity, nausea: def.nausea,
|
||||
isCustomCoaster: !!opts.coaster,
|
||||
track: opts.track || null, // coaster piece list
|
||||
train: opts.train || null,
|
||||
stats: opts.stats || null,
|
||||
entranceX: opts.entranceX ?? x, entranceY: opts.entranceY ?? y,
|
||||
exitTile: opts.exitTile || null,
|
||||
brokenCount: 0,
|
||||
};
|
||||
state.rides.push(ride);
|
||||
if (!opts.coaster) {
|
||||
for (let yy = 0; yy < def.h; yy++) for (let xx = 0; xx < def.w; xx++) {
|
||||
state.map.setObject(x + xx, y + yy, { kind: 'ride', id: ride.id, ox: xx, oy: yy });
|
||||
state.map.pathType[state.map.idx(x + xx, y + yy)] = 0;
|
||||
}
|
||||
}
|
||||
return ride;
|
||||
}
|
||||
|
||||
export function addShopObj(state, typeId, x, y) {
|
||||
const def = SHOP_TYPES[typeId];
|
||||
const shop = {
|
||||
id: uid(), type: typeId, def, x, y,
|
||||
price: def.price, stock: def.stock >= 999999 ? Infinity : def.stock,
|
||||
sold: 0, income: 0, damaged: 0,
|
||||
};
|
||||
state.shops.push(shop);
|
||||
state.map.setObject(x, y, { kind: 'shop', id: shop.id });
|
||||
state.map.pathType[state.map.idx(x, y)] = 0;
|
||||
return shop;
|
||||
}
|
||||
|
||||
export function addSceneryObj(state, typeId, x, y, free = false) {
|
||||
const def = SCENERY_TYPES[typeId];
|
||||
if (!def) return null;
|
||||
if (def.size === 2 && !free && !canPlaceRect(state.map, x, y, 2, 2)) return null;
|
||||
const obj = { id: uid(), type: typeId, def, x, y };
|
||||
state.sceneryList.push(obj);
|
||||
const size = def.size || 1;
|
||||
for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++)
|
||||
state.map.setObject(x + xx, y + yy, { kind: 'scenery', id: obj.id, ox: xx, oy: yy });
|
||||
return obj;
|
||||
}
|
||||
export function removeScenery(state, obj) {
|
||||
state.sceneryList = state.sceneryList.filter(o => o !== obj);
|
||||
const size = obj.def.size || 1;
|
||||
for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++) {
|
||||
const o = state.map.getObject(obj.x + xx, obj.y + yy);
|
||||
if (o && o.kind === 'scenery' && o.id === obj.id) state.map.clearObject(obj.x + xx, obj.y + yy);
|
||||
}
|
||||
}
|
||||
export function canPlaceRect(map, x, y, w, h) {
|
||||
for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) {
|
||||
if (!map.isBuildable(x + xx, y + yy) || map.occupied(x + xx, y + yy)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- main step ----------------
|
||||
export function step(state, dt) {
|
||||
if (state.won || state.lost) dt = Math.min(dt, 0); // freeze sim on end
|
||||
advanceTime(state, dt);
|
||||
tickWeather(state, dt);
|
||||
// imported lazily by main via update modules
|
||||
}
|
||||
|
||||
export function advanceTime(state, dt) {
|
||||
const t = state.time;
|
||||
t.hour += dt * HOUR_RATE;
|
||||
while (t.hour >= 24) {
|
||||
t.hour -= 24;
|
||||
t.day++;
|
||||
if (t.day > DAYS_PER_MONTH) {
|
||||
t.day = 1;
|
||||
t.month++;
|
||||
onNewMonth(state);
|
||||
if (t.month > 11) { t.month = 0; t.year++; onNewYear(state); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function monthIndex(state) { return state.time.year * 12 + state.time.month; }
|
||||
|
||||
function onNewMonth(state) {
|
||||
monthClose(state);
|
||||
tickCampaigns(state);
|
||||
quarterlyAwards(state);
|
||||
checkInvasionSchedule(state);
|
||||
state.autosaveMonthCounter++;
|
||||
state.toasts.push({ kind: 'month', title: 'New Month', text: `Welcome to ${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][state.time.month]}, Year ${state.time.year}.` });
|
||||
}
|
||||
|
||||
function onNewYear(state) {
|
||||
state.toasts.push({ kind: 'gold', title: `Year ${state.time.year} begins!`, text: 'The kingdom grows stronger.' });
|
||||
}
|
||||
|
||||
function quarterlyAwards(state) {
|
||||
if ((state.time.month % 3) !== 0) return;
|
||||
recomputeStats(state);
|
||||
const s = state.stats;
|
||||
for (const a of AWARDS_POOL) {
|
||||
if (state.awards.includes(a.id)) continue;
|
||||
try { if (a.test(s)) { state.awards.push(a.id); state.toasts.push({ kind: 'gold', title: 'Award Won!', text: `${a.name} — your park is famous!` }); } } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function checkInvasionSchedule(state) {
|
||||
const scen = SCENARIOS.find(s => s.id === state.scenario);
|
||||
if (!scen || !scen.invasionEvery) return;
|
||||
if (state.invasion.nextMonthIdx <= monthIndex(state)) {
|
||||
state.invasion.nextMonthIdx = monthIndex(state) + scen.invasionEvery;
|
||||
state.pendingInvasion = true; // consumed by heroes module
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- weather ----------------
|
||||
function tickWeather(state, dt) {
|
||||
state.weather.timer -= dt;
|
||||
if (state.weather.timer <= 0) {
|
||||
const roll = Math.random();
|
||||
const order = state.weather.cur === 'sunny' ? ['cloudy', 'sunny', 'rain']
|
||||
: state.weather.cur === 'cloudy' ? ['sunny', 'rain', 'cloudy', 'storm']
|
||||
: state.weather.cur === 'rain' ? ['cloudy', 'rain', 'sunny', 'storm']
|
||||
: ['rain', 'cloudy', 'sunny'];
|
||||
let next = order[0];
|
||||
if (roll < 0.45) next = order[0]; else if (roll < 0.75) next = order[1] || next; else next = order[order.length - 1];
|
||||
setWeather(state, next);
|
||||
state.weather.timer = 80 + Math.random() * 160;
|
||||
}
|
||||
// sunburst spell forces sunny
|
||||
if (state.spells.active.sunburst) setWeather(state, 'sunny');
|
||||
}
|
||||
export function setWeather(state, w) {
|
||||
if (state.weather.cur === w) return;
|
||||
state.weather.cur = w;
|
||||
state.toasts.push({ kind: 'info', title: `Weather: ${WEATHER[w].name}`, text: '' });
|
||||
}
|
||||
|
||||
// ---------------- mana ----------------
|
||||
export function recomputeManaCap(state) {
|
||||
let cap = 40, regen = 0.4;
|
||||
for (const sc of state.sceneryList) {
|
||||
if (sc.def.manaCap) cap += sc.def.manaCap;
|
||||
if (sc.def.manaRegen) regen += sc.def.manaRegen;
|
||||
}
|
||||
cap = Math.min(cap, 300);
|
||||
state.manaMax = cap;
|
||||
state.manaRegen = regen;
|
||||
}
|
||||
|
||||
// ---------------- stats & rating ----------------
|
||||
export function recomputeStats(state) {
|
||||
const m = state.map;
|
||||
let happySum = 0, happyN = 0;
|
||||
for (const g of state.guests) { happySum += g.happiness; happyN++; }
|
||||
const avgHappy = happyN ? happySum / happyN : 70;
|
||||
const litterCount = m.countLitter();
|
||||
let magicCount = 0, beautySum = 0, lightCount = 0;
|
||||
for (const sc of state.sceneryList) {
|
||||
beautySum += sc.def.beauty || 0;
|
||||
if (sc.def.magic) magicCount++;
|
||||
if (sc.def.light) lightCount++;
|
||||
}
|
||||
const openRideTypes = new Set();
|
||||
let openRides = 0, brokenRides = 0;
|
||||
for (const r of state.rides) {
|
||||
if (r.status === 'open') { openRides++; openRideTypes.add(r.type); }
|
||||
if (r.status === 'broken') brokenRides++;
|
||||
}
|
||||
let bestExcite = 0;
|
||||
for (const r of state.rides) bestExcite = Math.max(bestExcite, r.excite || 0);
|
||||
const hasFood = state.shops.some(s => s.type === 'food');
|
||||
const hasDrink = state.shops.some(s => s.type === 'drinks');
|
||||
const hasToilet = state.shops.some(s => s.type === 'toilet');
|
||||
const facilities = (hasFood ? 40 : 0) + (hasDrink ? 30 : 0) + (hasToilet ? 30 : 0);
|
||||
const pathTiles = countPaths(m);
|
||||
const avgBeauty = pathTiles ? beautySum / pathTiles : 0;
|
||||
|
||||
const rating = clamp(Math.round(
|
||||
Math.min(openRideTypes.size, 8) / 8 * 150 +
|
||||
avgHappy / 100 * 260 +
|
||||
Math.max(0, 190 - litterCount * 4 - state.vandalism * 12) +
|
||||
Math.min(avgBeauty * 14, 140) +
|
||||
facilities +
|
||||
Math.min(state.heroStats.kills * 1.2, 60)
|
||||
), 0, 999);
|
||||
|
||||
state.stats = {
|
||||
avgHappy, litterCount, avgBeauty, magicCount, openRides, openRideTypes: openRideTypes.size,
|
||||
brokenRides, bestExcite, facilities, rating,
|
||||
vandalism: state.vandalism,
|
||||
guests: state.guests.length,
|
||||
shops: state.shops.length,
|
||||
};
|
||||
state.ratingHistory.push(rating);
|
||||
if (state.ratingHistory.length > 240) state.ratingHistory.shift();
|
||||
return state.stats;
|
||||
}
|
||||
|
||||
function countPaths(m) {
|
||||
let c = 0;
|
||||
for (let i = 0; i < m.pathType.length; i++) if (m.pathType[i]) c++;
|
||||
return c;
|
||||
}
|
||||
|
||||
export function parkValue(state) {
|
||||
let v = state.cash - state.loan;
|
||||
for (const r of state.rides) v += r.def.cost * 0.7;
|
||||
for (const s of state.shops) v += s.def.cost * 0.7;
|
||||
for (const sc of state.sceneryList) v += (sc.def.cost || 0) * 0.5;
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
// ---------------- objectives ----------------
|
||||
export function objectiveProgress(state, goal) {
|
||||
switch (goal.type) {
|
||||
case 'guests': return state.guests.length;
|
||||
case 'rating': return state.stats?.rating || 0;
|
||||
case 'cash': return parkValue(state);
|
||||
case 'invasions': return state.invasion.repelled;
|
||||
case 'bossKill': return state.invasion.bossKilled ? 1 : 0;
|
||||
case 'coasterExcite': {
|
||||
let best = 0;
|
||||
for (const r of state.rides) if (r.isCustomCoaster && r.status === 'open') best = Math.max(best, r.excite);
|
||||
return best;
|
||||
}
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function checkObjectives(state) {
|
||||
if (state.won || state.freeplay) return;
|
||||
const scen = SCENARIOS.find(s => s.id === state.scenario);
|
||||
if (!scen || !scen.goals.length) return;
|
||||
let all = true;
|
||||
for (const g of scen.goals) {
|
||||
const done = objectiveProgress(state, g) >= g.value;
|
||||
if (!done) all = false;
|
||||
state.objectivesDone[g.id] = done;
|
||||
}
|
||||
if (all) {
|
||||
state.won = true;
|
||||
state.toasts.push({ kind: 'gold', title: '🏆 VICTORY!', text: `${scen.name} conquered — all objectives complete!` });
|
||||
}
|
||||
if (state.cash < scen.loseCash && !state.lost) {
|
||||
state.lost = true;
|
||||
state.toasts.push({ kind: 'bad', title: 'BANKRUPTCY', text: 'The kingdom has run out of gold…' });
|
||||
}
|
||||
}
|
||||
|
||||
export function isNight(t) { return t.hour >= 20 || t.hour < 6; }
|
||||
|
||||
// ---------------- research RP accrual ----------------
|
||||
export function tickResearch(state, dt) {
|
||||
let rate = 0.05;
|
||||
rate += state.stats.openRides * 0.02;
|
||||
rate += state.stats.magicCount * 0.03;
|
||||
if (state.guests.length > 50) rate += 0.03;
|
||||
state.research.rp += rate * dt;
|
||||
}
|
||||
|
||||
// ---------------- sandbox unlock ----------------
|
||||
export function unlockEverythingSync(state) {
|
||||
for (const u of UNLOCKS) if (!state.research.unlocked.includes(u.key)) state.research.unlocked.push(u.key);
|
||||
}
|
||||
|
||||
// ---------------- serialization ----------------
|
||||
export function serialize(state) {
|
||||
const m = state.map;
|
||||
return {
|
||||
...state,
|
||||
map: undefined,
|
||||
mapData: {
|
||||
size: m.size,
|
||||
terrain: Array.from(m.terrain),
|
||||
pathType: Array.from(m.pathType),
|
||||
litter: Array.from(m.litter),
|
||||
vomit: Array.from(m.vomit),
|
||||
objects: m.objects,
|
||||
scatterSeed: m.scatterSeed,
|
||||
entranceX: m.entranceX, entranceY: m.entranceY,
|
||||
},
|
||||
rngState: typeof state._rng?.getState === 'function' ? state._rng.getState() : 0,
|
||||
stats: undefined,
|
||||
_statsSnapshot: state.stats,
|
||||
};
|
||||
}
|
||||
|
||||
export function deserialize(data) {
|
||||
resetUid(data.startedAt % 100000 || 1);
|
||||
const scen = SCENARIOS.find(s => s.id === data.scenario) || SCENARIOS[0];
|
||||
const st = JSON.parse(JSON.stringify({ ...data, mapData: undefined }));
|
||||
const m = new GameMap(data.mapData.size);
|
||||
m.terrain = Uint8Array.from(data.mapData.terrain);
|
||||
m.pathType = Uint8Array.from(data.mapData.pathType);
|
||||
m.litter = Float32Array.from(data.mapData.litter);
|
||||
m.vomit = Float32Array.from(data.mapData.vomit);
|
||||
m.objects = data.mapData.objects;
|
||||
m.scatterSeed = data.mapData.scatterSeed;
|
||||
m.entranceX = data.mapData.entranceX; m.entranceY = data.mapData.entranceY;
|
||||
st.map = m;
|
||||
// restore def references lost through JSON
|
||||
for (const r of st.rides) { r.def = RIDE_TYPES[r.type]; }
|
||||
for (const s of st.shops) { s.def = SHOP_TYPES[s.type]; }
|
||||
for (const sc of st.sceneryList) { sc.def = SCENERY_TYPES[sc.type]; }
|
||||
for (const sf of st.staff) { sf.def = STAFF_TYPES[sf.type]; }
|
||||
// restore uid counter beyond any loaded id
|
||||
let maxId = 1;
|
||||
for (const arr of [st.rides, st.shops, st.sceneryList, st.staff, st.guests, st.heroes, st.monsters]) {
|
||||
if (!Array.isArray(arr)) continue;
|
||||
for (const e of arr) if (e && typeof e.id === 'number' && e.id > maxId) maxId = e.id;
|
||||
}
|
||||
resetUid(maxId + 1);
|
||||
st.stats = st._statsSnapshot || recomputeStats(st);
|
||||
st.rngSeed = data.rngSeed ?? 12345;
|
||||
setState(st);
|
||||
return st;
|
||||
}
|
||||
|
||||
/** attach runtime rng */
|
||||
export function ensureRng(state) {
|
||||
if (!state._rng || typeof state._rng !== 'function') {
|
||||
state._rng = makeRng(state.rngSeed || 42);
|
||||
}
|
||||
return state._rng;
|
||||
}
|
||||
Reference in New Issue
Block a user