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
100 lines
3.3 KiB
JavaScript
100 lines
3.3 KiB
JavaScript
// ============ 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;
|
|
}
|