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
+354
View File
@@ -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 });
}