Files
arcane-tycoon/js/game/state.js
T
deepseek ac00687480 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
2026-08-23 06:59:21 +00:00

452 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============ 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;
}