Arcane Tycoon — Heroes & Magic theme park tycoon game

Complete browser game inspired by OpenRCT2 with fantasy twist:
- Custom roller coaster designer with physics-based ratings + on-ride POV
- 10 animated rides, 7 shops, 16 scenery items, path network & guest AI
- Heroes guild vs monster invasions (5 classes, XP/gear/bosses)
- Magic spell system (8 spells), research tree, economy/marketing/loans
- Day-night cycle, weather, park rating, awards, 4 scenarios
- Save/load slots + autosave, procedural WebAudio SFX/music
- Isometric canvas renderer, minimap, diagnostics overlay
- Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
This commit is contained in:
2026-08-23 06:59:21 +00:00
commit ac00687480
30 changed files with 6772 additions and 0 deletions
+451
View File
@@ -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;
}