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,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