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,111 @@
|
||||
// ============ audio.js — procedural WebAudio SFX & ambient music ============
|
||||
let AC = null;
|
||||
let masterGain = null, musicGain = null, sfxGain = null;
|
||||
let musicTimer = null;
|
||||
let enabled = true;
|
||||
const settings = { master: 0.7, music: 0.5, sfx: 0.8 };
|
||||
|
||||
export function initAudio() {
|
||||
if (AC) return;
|
||||
try {
|
||||
AC = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = AC.createGain();
|
||||
musicGain = AC.createGain();
|
||||
sfxGain = AC.createGain();
|
||||
musicGain.connect(masterGain);
|
||||
sfxGain.connect(masterGain);
|
||||
masterGain.connect(AC.destination);
|
||||
applyVolumes();
|
||||
} catch (e) { console.warn('Audio unavailable', e); enabled = false; }
|
||||
}
|
||||
export function audioSupported() { return !!AC; }
|
||||
export function setVolumes(v) {
|
||||
Object.assign(settings, v);
|
||||
try { localStorage.setItem('at_audio', JSON.stringify(settings)); } catch { }
|
||||
applyVolumes();
|
||||
}
|
||||
export function getVolumes() { return { ...settings }; }
|
||||
function applyVolumes() {
|
||||
if (!AC) return;
|
||||
masterGain.gain.value = settings.master;
|
||||
musicGain.gain.value = settings.music * 0.5;
|
||||
sfxGain.gain.value = settings.sfx;
|
||||
}
|
||||
|
||||
// resume on first user gesture
|
||||
export function unlockAudio() {
|
||||
initAudio();
|
||||
if (AC && AC.state === 'suspended') AC.resume();
|
||||
}
|
||||
|
||||
function tone(freq, dur, type = 'sine', vol = 0.3, dest, slideTo) {
|
||||
if (!AC || !enabled) return;
|
||||
const o = AC.createOscillator();
|
||||
const g = AC.createGain();
|
||||
o.type = type;
|
||||
o.frequency.value = freq;
|
||||
if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), AC.currentTime + dur);
|
||||
g.gain.setValueAtTime(vol, AC.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + dur);
|
||||
o.connect(g); g.connect(dest || sfxGain);
|
||||
o.start(); o.stop(AC.currentTime + dur + 0.02);
|
||||
}
|
||||
function noise(dur, vol = 0.2, filterFreq = 800) {
|
||||
if (!AC || !enabled) return;
|
||||
const len = AC.sampleRate * dur;
|
||||
const buf = AC.createBuffer(1, len, AC.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||
const src = AC.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const f = AC.createBiquadFilter();
|
||||
f.type = 'lowpass'; f.frequency.value = filterFreq;
|
||||
const g = AC.createGain();
|
||||
g.gain.setValueAtTime(vol, AC.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + dur);
|
||||
src.connect(f); f.connect(g); g.connect(sfxGain);
|
||||
src.start();
|
||||
}
|
||||
|
||||
export const sfx = {
|
||||
click() { tone(660, .06, 'square', .12); },
|
||||
place() { noise(.08, .25, 500); tone(220, .1, 'triangle', .2); },
|
||||
demolish() { noise(.22, .3, 300); },
|
||||
cash() { tone(880, .09, 'sine', .18); setTimeout(() => tone(1320, .12, 'sine', .16), 70); },
|
||||
error() { tone(180, .18, 'sawtooth', .15, null, 120); },
|
||||
openRide() { [440, 554, 659, 880].forEach((f, i) => setTimeout(() => tone(f, .14, 'triangle', .15), i * 90)); },
|
||||
whoosh() { noise(.5, .18, 900); },
|
||||
spell() {
|
||||
if (!AC) return;
|
||||
[660, 830, 990, 1245].forEach((f, i) => setTimeout(() => tone(f, .3, 'sine', .12), i * 60));
|
||||
},
|
||||
hit() { noise(.07, .28, 700); tone(140, .08, 'square', .14); },
|
||||
monsterRoar() { tone(90, .5, 'sawtooth', .25, null, 50); noise(.4, .2, 250); },
|
||||
victory() { [523, 659, 784, 1046].forEach((f, i) => setTimeout(() => tone(f, .35, 'triangle', .2), i * 160)); },
|
||||
defeat() { [400, 340, 280, 200].forEach((f, i) => setTimeout(() => tone(f, .4, 'sawtooth', .15), i * 200)); },
|
||||
levelup() { [600, 750, 900].forEach((f, i) => setTimeout(() => tone(f, .12, 'square', .1), i * 80)); },
|
||||
};
|
||||
|
||||
// ------- generative ambient music: gentle fantasy pad loop -------
|
||||
const SCALE = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25]; // C pentatonic-ish
|
||||
let musicOn = false;
|
||||
export function startMusic() {
|
||||
if (!AC || musicOn) return;
|
||||
musicOn = true;
|
||||
const stepFn = () => {
|
||||
if (!musicOn) return;
|
||||
// soft chord every ~2s
|
||||
const root = SCALE[Math.floor(Math.random() * 3)];
|
||||
const third = SCALE[Math.floor(Math.random() * SCALE.length)];
|
||||
tone(root / 2, 2.4, 'sine', .10, musicGain);
|
||||
tone(third, 2.2, 'triangle', .05, musicGain);
|
||||
if (Math.random() < 0.4) tone(root * 2, 1.8, 'sine', .04, musicGain);
|
||||
musicTimer = setTimeout(stepFn, 1800 + Math.random() * 900);
|
||||
};
|
||||
stepFn();
|
||||
}
|
||||
export function stopMusic() {
|
||||
musicOn = false;
|
||||
if (musicTimer) clearTimeout(musicTimer);
|
||||
}
|
||||
export function isMusicOn() { return musicOn; }
|
||||
@@ -0,0 +1,308 @@
|
||||
// ============ config.js — all game data & tuning constants ============
|
||||
|
||||
export const TILE_W = 64, TILE_H = 32; // iso tile size at zoom 1
|
||||
export const Z_STEP = 14; // pixels per height unit at zoom 1
|
||||
|
||||
export const TERRAIN = {
|
||||
0: { name: 'Grass', base: '#4d8a3d', alt: '#57a047', walk: false },
|
||||
1: { name: 'Sand', base: '#cbb26a', alt: '#d5bd77', walk: false },
|
||||
2: { name: 'Rock', base: '#7a7f8a', alt: '#868b96', walk: false },
|
||||
3: { name: 'Water', base: '#2e6db4', alt: '#3a7cc9', walk: false },
|
||||
};
|
||||
|
||||
// Directions: E, S, W, N (screen: +x = lower-right, +y = lower-left)
|
||||
export const DIRS = [[1, 0], [0, 1], [-1, 0], [0, -1]];
|
||||
export const DIR_NAMES = ['E', 'S', 'W', 'N'];
|
||||
|
||||
export const PATH_TYPES = {
|
||||
pavement: { name: 'Pavement', cost: 10, color: '#b8b2a5', edge: '#8d887c' },
|
||||
cobble: { name: 'Cobblestone', cost: 14, color: '#6f6a80', edge: '#514d60' },
|
||||
};
|
||||
|
||||
// ---------------- RIDES ----------------
|
||||
// stats: excite/intensity/nausea base; rideTime sec; capacity guests per cycle
|
||||
export const RIDE_TYPES = {
|
||||
carousel: {
|
||||
id: 'carousel', name: 'Unicorn Carousel', icon: '🎠', w: 2, h: 2, cost: 2200,
|
||||
runCost: 18, rideTime: 22, capacity: 12, excite: 2.4, intensity: 1.6, nausea: 1.1,
|
||||
tier: 0, desc: 'A gentle classic. Glittering unicorns spin beneath golden canopy.',
|
||||
color: '#e59ae0', color2: '#f5c542',
|
||||
},
|
||||
ferris: {
|
||||
id: 'ferris', name: 'Sky Wheel', icon: '🎡', w: 3, h: 3, cost: 4200,
|
||||
runCost: 30, rideTime: 40, capacity: 20, excite: 3.2, intensity: 2.0, nausea: 1.4,
|
||||
tier: 0, desc: 'Tower above the park in a glass gondola with views for miles.',
|
||||
color: '#7fb2ff', color2: '#ffd166',
|
||||
},
|
||||
teacups: {
|
||||
id: 'teacups', name: "Wizard's Teacups", icon: '🫖', w: 2, h: 2, cost: 1800,
|
||||
runCost: 15, rideTime: 25, capacity: 15, excite: 2.8, intensity: 3.4, nausea: 4.2,
|
||||
tier: 0, desc: 'Spinning cups of a very caffeinated sorcerer. Nauseating fun!',
|
||||
color: '#ff9d76', color2: '#ffe08a',
|
||||
},
|
||||
drop_tower: {
|
||||
id: 'drop_tower', name: 'Gravity Spire', icon: '🗼', w: 2, h: 2, cost: 5200,
|
||||
runCost: 38, rideTime: 18, capacity: 16, excite: 5.6, intensity: 7.2, nausea: 3.4,
|
||||
tier: 1, desc: 'Rise 40 meters… then let gravity have you. A breath-stealer.',
|
||||
color: '#b0b6c8', color2: '#ff5c5c',
|
||||
},
|
||||
swings: {
|
||||
id: 'swings', name: 'Fairy Swings', icon: '🎐', w: 3, h: 2, cost: 3400,
|
||||
runCost: 24, rideTime: 28, capacity: 16, excite: 3.6, intensity: 3.0, nausea: 2.4,
|
||||
tier: 1, desc: 'Fly on enchanted chairs steered by tiny fairies.',
|
||||
color: '#a5e6ff', color2: '#f7a8ff',
|
||||
},
|
||||
haunted: {
|
||||
id: 'haunted', name: 'Haunted Crypt', icon: '👻', w: 3, h: 3, cost: 4800,
|
||||
runCost: 34, rideTime: 45, capacity: 18, excite: 5.2, intensity: 4.6, nausea: 2.0,
|
||||
tier: 1, desc: 'Dark ride through crypts guarded by very committed ghosts.',
|
||||
color: '#9a86c8', color2: '#5de0c8',
|
||||
},
|
||||
logflume: {
|
||||
id: 'logflume', name: 'River Sprite Flume', icon: '🛶', w: 4, h: 6, cost: 7200,
|
||||
runCost: 46, rideTime: 70, capacity: 20, excite: 5.8, intensity: 4.8, nausea: 3.0,
|
||||
tier: 2, desc: 'Meandering river channel ending in a mighty splash.',
|
||||
color: '#63c5ea', color2: '#8d5a2b',
|
||||
},
|
||||
dragon_coaster: {
|
||||
id: 'dragon_coaster', name: 'Dragonling Coaster', icon: '🐉', w: 5, h: 5, cost: 9000,
|
||||
runCost: 55, rideTime: 60, capacity: 24, excite: 6.8, intensity: 6.4, nausea: 3.6,
|
||||
tier: 2, desc: 'A ready-made junior coaster ridden on friendly young dragons.',
|
||||
color: '#66d977', color2: '#f43f5e',
|
||||
},
|
||||
portal: {
|
||||
id: 'portal', name: 'Portal Blasters', icon: '🌀', w: 3, h: 3, cost: 8500,
|
||||
runCost: 50, rideTime: 50, capacity: 16, excite: 6.2, intensity: 5.4, nausea: 2.2,
|
||||
tier: 2, desc: 'Shoot glowing orbs across dimensions from your hover-chair.',
|
||||
color: '#a86bff', color2: '#58c1ff',
|
||||
},
|
||||
broom_tower: {
|
||||
id: 'broom_tower', name: 'Broomstick Tower', icon: '🧹', w: 2, h: 2, cost: 6800,
|
||||
runCost: 42, rideTime: 35, capacity: 14, excite: 6.0, intensity: 5.0, nausea: 3.2,
|
||||
tier: 3, desc: 'Straddle a racing broom as it spirals up and around the spire.',
|
||||
color: '#c98d4e', color2: '#ffd166',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- COASTER PIECES ----------------
|
||||
export const PIECES = {
|
||||
straight: { id: 'straight', name: 'Straight', icon: '━', cost: 120, dz: 0, turn: 0 },
|
||||
curveL: { id: 'curveL', name: 'Curve Left', icon: '↰', cost: 140, dz: 0, turn: -1 },
|
||||
curveR: { id: 'curveR', name: 'Curve Right',icon: '↱', cost: 140, dz: 0, turn: 1 },
|
||||
up: { id: 'up', name: 'Slope Up', icon: '↗', cost: 170, dz: 1, turn: 0 },
|
||||
down: { id: 'down', name: 'Slope Down', icon: '↘', cost: 150, dz: -1, turn: 0 },
|
||||
steepUp: { id: 'steepUp', name: 'Steep Up', icon: '⇗', cost: 210, dz: 2, turn: 0 },
|
||||
steepDown: { id: 'steepDown', name: 'Steep Drop', icon: '⇘', cost: 190, dz: -2, turn: 0 },
|
||||
loop: { id: 'loop', name: 'Loop', icon: '◯', cost: 400, dz: 0, turn: 0 },
|
||||
station: { id: 'station', name: 'Station', icon: '▤', cost: 300, dz: 0, turn: 0 },
|
||||
};
|
||||
export const MAX_Z = 14;
|
||||
export const MIN_COASTER_PIECES = 6;
|
||||
|
||||
// ---------------- SHOPS ----------------
|
||||
export const SHOP_TYPES = {
|
||||
drinks: { id: 'drinks', name: 'Potion Fizz', icon: '🥤', cost: 500, price: 6, need: 'thirst', needFix: 75, stock: 250, tier: 0, desc: 'Sparkling tonics in wild colors.' },
|
||||
food: { id: 'food', name: 'Dragon Grill', icon: '🍗', cost: 650, price: 9, need: 'hunger', needFix: 80, stock: 250, tier: 0, desc: 'Flame-grilled drumsticks (mildly fireproof).' },
|
||||
icecream: { id: 'icecream', name: 'Frost Imp Cream', icon: '🍦', cost: 550, price: 7, need: 'thirst', needFix: 55, stock: 200, tier: 0, desc: 'Ice cream that whispers frost puns.' },
|
||||
souvenir: { id: 'souvenir', name: 'Curiosity Shop', icon: '🎁', cost: 700, price: 14, need: 'shop', needFix: 100, stock: 999999, tier: 0, desc: 'Wands, hats and questionable relics.', happyBoost: 8 },
|
||||
toilet: { id: 'toilet', name: 'Restrooms', icon: '🚻', cost: 400, price: 2, need: 'toilet', needFix: 100, stock: 999999, tier: 0, desc: 'Essential plumbing. Guests will thank you.' },
|
||||
balloon: { id: 'balloon', name: 'Balloon Stall', icon: '🎈', cost: 350, price: 5, need: 'shop', needFix: 100, stock: 999999, tier: 0, desc: 'Floating joy on a string.', happyBoost: 6 },
|
||||
firstaid: { id: 'firstaid', name: 'Healers Hut', icon: '⛑️', cost: 600, price: 0, need: 'health', needFix: 100, stock: 999999, tier: 1, desc: 'Patch up guests who overdid the loops.' },
|
||||
};
|
||||
|
||||
// ---------------- SCENERY ----------------
|
||||
export const SCENERY_TYPES = {
|
||||
tree_oak: { id: 'tree_oak', name: 'Oak Tree', icon: '🌳', cost: 45, size: 1, beauty: 3 },
|
||||
tree_pine: { id: 'tree_pine', name: 'Pine Tree', icon: '🌲', cost: 45, size: 1, beauty: 3 },
|
||||
tree_cherry: { id: 'tree_cherry', name: 'Cherry Blossom',icon: '🌸', cost: 90, size: 1, beauty: 6, tier: 1 },
|
||||
flowerbed: { id: 'flowerbed', name: 'Flower Bed', icon: '🌷', cost: 35, size: 1, beauty: 4 },
|
||||
hedge: { id: 'hedge', name: 'Hedge', icon: '🌿', cost: 30, size: 1, beauty: 2 },
|
||||
bench: { id: 'bench', name: 'Bench', icon: '🪑', cost: 40, size: 1, beauty: 1, rest: true },
|
||||
bin: { id: 'bin', name: 'Litter Bin', icon: '🗑️', cost: 30, size: 1, beauty: 0, antiLitter: 6 },
|
||||
lamp: { id: 'lamp', name: 'Street Lamp', icon: '🏮', cost: 55, size: 1, beauty: 2, light: 4 },
|
||||
fountain: { id: 'fountain', name: 'Fountain', icon: '⛲', cost: 380, size: 1, beauty: 10, tier: 0 },
|
||||
statue_knight:{id:'statue_knight',name: 'Knight Statue', icon: '🗿', cost: 260, size: 1, beauty: 8, tier: 1 },
|
||||
statue_dragon:{id:'statue_dragon',name: 'Dragon Statue', icon: '🐲', cost: 520, size: 2, beauty: 14, tier: 2 },
|
||||
crystal_lamp:{ id: 'crystal_lamp',name: 'Crystal Lamp', icon: '💎', cost: 160, size: 1, beauty: 5, light: 7, manaCap: 5, tier: 1 },
|
||||
ley_pool: { id: 'ley_pool', name: 'Ley Pool', icon: '🔮', cost: 800, size: 2, beauty: 8, manaCap: 25, manaRegen: .5, light: 6, tier: 0, magic: true, desc: 'A pool of raw magic. Raises max mana & regen.' },
|
||||
rune_stone: { id: 'rune_stone', name: 'Rune Stone', icon: '🪨', cost: 300, size: 1, beauty: 6, manaCap: 10, manaRegen: .2, tier: 1, magic: true, desc: 'Ancient stone humming with power.' },
|
||||
mushroom_glow:{id:'mushroom_glow',name: 'Glowcap Cluster',icon:'🍄', cost: 120, size: 1, beauty: 5, light: 5, manaCap: 3, tier: 1, magic: true },
|
||||
banner: { id: 'banner', name: 'Park Banner', icon: '🚩', cost: 50, size: 1, beauty: 3 },
|
||||
};
|
||||
|
||||
// ---------------- STAFF ----------------
|
||||
export const STAFF_TYPES = {
|
||||
handyman: { id: 'handyman', name: 'Handyman', icon: '🧹', wage: 14, desc: 'Sweeps litter & vomit, waters flowers.' },
|
||||
mechanic: { id: 'mechanic', name: 'Mechanic', icon: '🔧', wage: 22, desc: 'Inspects & repairs rides.' },
|
||||
guard: { id: 'guard', name: 'Guard', icon: '💂', wage: 18, desc: 'Deters vandalism near shops & rides.' },
|
||||
entertainer: { id: 'entertainer', name: 'Court Jester', icon: '🤡', wage: 16, desc: 'Entertains queuing guests (+happiness).' },
|
||||
};
|
||||
|
||||
// ---------------- HEROES ----------------
|
||||
export const HERO_CLASSES = {
|
||||
knight: { id: 'knight', name: 'Knight', icon: '🛡️', hp: 130, dmg: 14, range: 1.2, speed: 1.9, atkCd: 1.1, cost: 800, tier: 0, desc: 'Stalwart frontline defender.' },
|
||||
ranger: { id: 'ranger', name: 'Ranger', icon: '🏹', hp: 75, dmg: 11, range: 4.0, speed: 2.4, atkCd: .9, cost: 750, tier: 0, desc: 'Strikes from afar with enchanted arrows.' },
|
||||
mage: { id: 'mage', name: 'Battle Mage', icon: '🧙', hp: 60, dmg: 19, range: 3.4, speed: 1.8, atkCd: 1.6, cost: 950, tier: 0, aoe: 1.6, desc: 'Hurls arcane bolts that splash damage.' },
|
||||
cleric: { id: 'cleric', name: 'Cleric', icon: '⚕️', hp: 70, dmg: 4, range: 3.2, speed: 2.0, atkCd: 1.4, cost: 900, tier: 1, heal: 10, desc: 'Heals nearby heroes every second.' },
|
||||
paladin: { id: 'paladin', name: 'Paladin', icon: '⚔️', hp: 175, dmg: 17, range: 1.3, speed: 1.8, atkCd: 1.2, cost: 1500, tier: 2, desc: 'Holy warrior with immense staying power.' },
|
||||
};
|
||||
export const MAX_HEROES = 6;
|
||||
|
||||
// ---------------- MONSTERS ----------------
|
||||
export const MONSTER_TYPES = {
|
||||
slime: { id: 'slime', name: 'Slime', icon: '🟢', hp: 34, dmg: 6, speed: .9, gold: 40, xp: 10, threat: 1 },
|
||||
goblin: { id: 'goblin', name: 'Goblin', icon: '👺', hp: 52, dmg: 9, speed: 1.6, gold: 60, xp: 16, threat: 2 },
|
||||
wolf: { id: 'wolf', name: 'Dire Wolf', icon: '🐺', hp: 44, dmg: 11, speed: 2.4, gold: 70, xp: 18, threat: 2 },
|
||||
brute: { id: 'brute', name: 'Troll Brute', icon: '👹', hp: 240, dmg: 24, speed: .95, gold: 260, xp: 60, threat: 5 },
|
||||
boss: { id: 'boss', name: 'Void Wraith', icon: '☠️', hp: 700, dmg: 34, speed: 1.2, gold: 900, xp: 200, threat: 10 },
|
||||
};
|
||||
|
||||
// ---------------- SPELLS ----------------
|
||||
export const SPELLS = {
|
||||
sunburst: { id: 'sunburst', name: 'Sunburst', icon: '☀️', mana: 15, cd: 60, dur: 0, tier: 0,
|
||||
desc: 'Instantly clear the skies to sunny weather.' },
|
||||
joy_aura: { id: 'joy_aura', name: 'Joy Aura', icon: '😊', mana: 25, cd: 45, dur: 20, tier: 0,
|
||||
desc: 'All guests gain steady happiness while active.' },
|
||||
healing_light: { id: 'healing_light', name: 'Healing Light', icon: '💚', mana: 30, cd: 50, dur: 0, tier: 0,
|
||||
desc: 'Fully heals all heroes instantly.' },
|
||||
fortune_rain: { id: 'fortune_rain', name: 'Fortune Rain', icon: '💸', mana: 40, cd: 100, dur: 30, tier: 1,
|
||||
desc: 'Guests spend 60% more for the duration.' },
|
||||
swift_build: { id: 'swift_build', name: "Artificer's Haste", icon: '⚡', mana: 35, cd: 130, dur: 25, tier: 1,
|
||||
desc: 'Construction is instant and half price while active.' },
|
||||
monster_bane: { id: 'monster_bane', name: 'Monster Bane', icon: '💥', mana: 45, cd: 90, dur: 0, tier: 2,
|
||||
desc: 'Deals 70 damage to every monster in the park.' },
|
||||
warding_sigil: { id: 'warding_sigil', name: 'Warding Sigil', icon: '🛡️', mana: 50, cd: 160, dur: 60, tier: 2,
|
||||
desc: 'Blocks invasions; monsters flee while active.' },
|
||||
transmute: { id: 'transmute', name: 'Transmutation', icon: '🪙', mana: 55, cd: 150, dur: 0, tier: 3,
|
||||
desc: 'Conjure $900 from thin air.' },
|
||||
};
|
||||
|
||||
// ---------------- RESEARCH ----------------
|
||||
export const RESEARCH_TRACKS = {
|
||||
rides: { id: 'rides', name: 'Ride Engineering', icon: '🎢' },
|
||||
trade: { id: 'trade', name: 'Commerce', icon: '🏪' },
|
||||
magic: { id: 'magic', name: 'Arcane Studies', icon: '✨' },
|
||||
heroes: { id: 'heroes', name: 'Heroes Guild', icon: '⚔️' },
|
||||
};
|
||||
// unlock entries: {key, track, rp, label}
|
||||
export const UNLOCKS = [
|
||||
// rides
|
||||
{ key: 'drop_tower', track: 'rides', rp: 110, label: 'Gravity Spire', kind: 'ride' },
|
||||
{ key: 'swings', track: 'rides', rp: 80, label: 'Fairy Swings', kind: 'ride' },
|
||||
{ key: 'haunted', track: 'rides', rp: 140, label: 'Haunted Crypt', kind: 'ride' },
|
||||
{ key: 'logflume', track: 'rides', rp: 220, label: 'River Sprite Flume', kind: 'ride' },
|
||||
{ key: 'dragon_coaster', track: 'rides', rp: 260, label: 'Dragonling Coaster', kind: 'ride' },
|
||||
{ key: 'portal', track: 'rides', rp: 300, label: 'Portal Blasters', kind: 'ride' },
|
||||
{ key: 'broom_tower',track: 'rides', rp: 380, label: 'Broomstick Tower', kind: 'ride' },
|
||||
// commerce
|
||||
{ key: 'icecream', track: 'trade', rp: 60, label: 'Frost Imp Cream', kind: 'shop' },
|
||||
{ key: 'balloon', track: 'trade', rp: 40, label: 'Balloon Stall', kind: 'shop' },
|
||||
{ key: 'firstaid', track: 'trade', rp: 90, label: 'Healers Hut', kind: 'shop' },
|
||||
{ key: 'cobble', track: 'trade', rp: 50, label: 'Cobblestone paths', kind: 'path' },
|
||||
{ key: 'statue_dragon', track: 'trade', rp: 130, label: 'Dragon Statue', kind: 'scenery' },
|
||||
// magic
|
||||
{ key: 'ley_pool', track: 'magic', rp: 100, label: 'Ley Pool', kind: 'scenery' },
|
||||
{ key: 'rune_stone', track: 'magic', rp: 70, label: 'Rune Stone', kind: 'scenery' },
|
||||
{ key: 'mushroom_glow', track: 'magic', rp: 50, label: 'Glowcap Cluster', kind: 'scenery' },
|
||||
{ key: 'crystal_lamp', track: 'magic', rp: 60, label: 'Crystal Lamp', kind: 'scenery' },
|
||||
{ key: 'tree_cherry', track: 'magic', rp: 40, label: 'Cherry Blossom', kind: 'scenery' },
|
||||
{ key: 'fortune_rain', track: 'magic', rp: 120, label: 'Spell: Fortune Rain', kind: 'spell' },
|
||||
{ key: 'swift_build', track: 'magic', rp: 130, label: 'Spell: Artificer\u2019s Haste', kind: 'spell' },
|
||||
{ key: 'monster_bane', track: 'magic', rp: 200, label: 'Spell: Monster Bane', kind: 'spell' },
|
||||
{ key: 'warding_sigil',track: 'magic', rp: 240, label: 'Spell: Warding Sigil', kind: 'spell' },
|
||||
{ key: 'transmute', track: 'magic', rp: 320, label: 'Spell: Transmutation', kind: 'spell' },
|
||||
// heroes
|
||||
{ key: 'cleric', track: 'heroes', rp: 110, label: 'Cleric class', kind: 'hero' },
|
||||
{ key: 'paladin', track: 'heroes', rp: 260, label: 'Paladin class', kind: 'hero' },
|
||||
{ key: 'guild2', track: 'heroes', rp: 150, label: 'Guild Hall II (+2 roster)', kind: 'heroCap' },
|
||||
{ key: 'gear2', track: 'heroes', rp: 190, label: 'Fine Gear (+25% hero power)', kind: 'heroGear' },
|
||||
];
|
||||
|
||||
// ---------------- GUESTS ----------------
|
||||
export const GUEST_NAMES = ['Ada','Bram','Cora','Dilan','Elke','Finn','Gwen','Hugo','Iris','Jasper','Kira','Liam','Mira','Noah','Odette','Pim','Quinn','Rosa','Sven','Tilda','Ulf','Vera','Wren','Xavi','Yara','Zane','Bree','Cato','Dora','Eppo','Faye','Gus','Hanne','Ivo','Jet','Koos','Loes','Mads','Nienke','Otto','Puck','Rens','Saar','Ties','Usko','Vos','Wilma','Ylva','Zeno'];
|
||||
export const GUEST_COLORS = ['#e05b5b','#5b8ee0','#57d97a','#e0b23d','#b45be0','#5bd9c9','#ff9d76','#8fa3ff'];
|
||||
|
||||
export const THOUGHTS = {
|
||||
great_ride: ['{r} was amazing!', 'Best. Ride. Ever!', '{r} made my day!'],
|
||||
good_ride: ['{r} was fun!', 'Enjoyed {r} a lot.'],
|
||||
meh_ride: ["{r} was okay I guess.", '{r} could be better…'],
|
||||
scary: ['{r} was terrifying!', 'Never again on {r}!'],
|
||||
hungry: ["I'm starving", 'Need food soon'],
|
||||
thirsty: ["I'm so thirsty", 'Could really use a drink'],
|
||||
toilet: ['I need a restroom!', 'Where are the toilets?!'],
|
||||
broke: ["I've spent all my money", 'Everything costs gold…'],
|
||||
happy_park: ['What a lovely park!', 'This place is magical!'],
|
||||
litter: ['All this litter…', 'Someone should clean up!'],
|
||||
vandal: ['Vandals everywhere!', 'This park feels unsafe'],
|
||||
monster: ['MONSTER!! Run!', 'Help! Heroes! Help!'],
|
||||
long_queue: ['Such a long queue…', 'Waiting forever for {r}'],
|
||||
broken: ['{r} has broken down again!'],
|
||||
expensive: ['{r} is pricey…', 'Entrance fee is steep!'],
|
||||
no_exit: ["Can't find the way out!", 'This maze of paths…'],
|
||||
};
|
||||
|
||||
// ---------------- WEATHER ----------------
|
||||
export const WEATHER = {
|
||||
sunny: { id: 'sunny', name: 'Sunny', icon: '☀️', spawnMul: 1.15, happyDrain: -.02, tint: null },
|
||||
cloudy: { id: 'cloudy', name: 'Cloudy', icon: '⛅', spawnMul: 1.0, happyDrain: 0, tint: 'rgba(120,130,160,.08)' },
|
||||
rain: { id: 'rain', name: 'Rain', icon: '🌧️', spawnMul: .6, happyDrain: .05, tint: 'rgba(60,80,140,.18)' },
|
||||
storm: { id: 'storm', name: 'Storm', icon: '⛈️', spawnMul: .35, happyDrain: .12, tint: 'rgba(30,40,90,.28)' },
|
||||
};
|
||||
|
||||
// ---------------- SCENARIOS ----------------
|
||||
export const SCENARIOS = [
|
||||
{
|
||||
id: 'meadows', name: 'Enchanted Meadows', diff: 'EASY', icon: '🌻',
|
||||
blurb: 'Rolling green fields beside a sleepy lake. Perfect ground for a first magical kingdom.',
|
||||
cash: 30000, loanLimit: 20000, mapSeed: 1337, mapSize: 52,
|
||||
gen: { lake: 1, trees: 90, rocks: 12, sand: true },
|
||||
goals: [
|
||||
{ id: 'guests', text: 'Have 220 guests in the park', type: 'guests', value: 220 },
|
||||
{ id: 'rating', text: 'Reach park rating 450', type: 'rating', value: 450 },
|
||||
{ id: 'cash', text: 'Grow park value to $45,000', type: 'cash', value: 45000 },
|
||||
],
|
||||
loseCash: -8000, invasionStartMonth: 4, invasionEvery: 4, invasionScale: 1,
|
||||
},
|
||||
{
|
||||
id: 'dragonspine', name: 'Dragonspine Pass', diff: 'MEDIUM', icon: '🏔️',
|
||||
blurb: 'A rocky mountain pass where dragons nest. Build fast — the horde comes early and often.',
|
||||
cash: 26000, loanLimit: 25000, mapSeed: 4242, mapSize: 56,
|
||||
gen: { lake: 1, trees: 60, rocks: 60, sand: false, rocky: true },
|
||||
goals: [
|
||||
{ id: 'guests', text: 'Have 320 guests in the park', type: 'guests', value: 320 },
|
||||
{ id: 'rating', text: 'Reach park rating 550', type: 'rating', value: 550 },
|
||||
{ id: 'invasions', text: 'Repel 3 monster invasions', type: 'invasions', value: 3 },
|
||||
{ id: 'coaster', text: 'Open a custom coaster with excitement ≥ 5.0', type: 'coasterExcite', value: 5 },
|
||||
],
|
||||
loseCash: -10000, invasionStartMonth: 2, invasionEvery: 3, invasionScale: 1.4,
|
||||
},
|
||||
{
|
||||
id: 'voidrift', name: 'Void Rift Crisis', diff: 'HARD', icon: '🌌',
|
||||
blurb: 'The sky is torn. Void wraiths pour through rifts while guests still demand roller coasters. Heroes wanted.',
|
||||
cash: 24000, loanLimit: 30000, mapSeed: 9021, mapSize: 56,
|
||||
gen: { lake: 1, trees: 40, rocks: 80, sand: false, rocky: true, void: true },
|
||||
goals: [
|
||||
{ id: 'guests', text: 'Have 420 guests in the park', type: 'guests', value: 420 },
|
||||
{ id: 'rating', text: 'Reach park rating 620', type: 'rating', value: 620 },
|
||||
{ id: 'boss', text: 'Defeat a Void Wraith', type: 'bossKill', value: 1 },
|
||||
{ id: 'coaster', text: 'Open a custom coaster with excitement ≥ 6.0', type: 'coasterExcite', value: 6 },
|
||||
],
|
||||
loseCash: -12000, invasionStartMonth: 1, invasionEvery: 2, invasionScale: 1.9, bossAt: 3,
|
||||
},
|
||||
{
|
||||
id: 'sandbox', name: 'Sandbox Kingdom', diff: 'SANDBOX', icon: '🧪',
|
||||
blurb: 'Unlimited money, everything unlocked. Build the impossible.',
|
||||
cash: 1000000, loanLimit: 0, mapSeed: 777, mapSize: 64, sandbox: true,
|
||||
gen: { lake: 2, trees: 110, rocks: 20, sand: true },
|
||||
goals: [], loseCash: -999999999, invasionStartMonth: 6, invasionEvery: 5, invasionScale: 1.2,
|
||||
},
|
||||
];
|
||||
|
||||
export const AWARDS_POOL = [
|
||||
{ id: 'prettiest', name: 'Prettiest Park', test: s => s.avgBeauty > 4 },
|
||||
{ id: 'safest', name: 'Safest Kingdom', test: s => s.heroStats.kills > 20 && s.vandalism < 3 },
|
||||
{ id: 'tidiest', name: 'Tidiest Park', test: s => s.litterCount < 8 },
|
||||
{ id: 'thrills', name: 'Best Thrills', test: s => s.bestExcite >= 7 },
|
||||
{ id: 'foodie', name: 'Finest Dining', test: s => s.shops.length >= 5 && s.avgHappy > 65 },
|
||||
{ id: 'magical', name: 'Most Magical Park', test: s => s.magicCount >= 6 },
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
// ============ util.js — shared helpers ============
|
||||
|
||||
export const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
|
||||
export const lerp = (a, b, t) => a + (b - a) * t;
|
||||
export const dist2 = (x1, y1, x2, y2) => { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; };
|
||||
export const dist = (x1, y1, x2, y2) => Math.sqrt(dist2(x1, y1, x2, y2));
|
||||
|
||||
let _uid = 1;
|
||||
export const uid = () => _uid++;
|
||||
export function resetUid(v) { _uid = v; }
|
||||
export const curUid = () => _uid;
|
||||
|
||||
/** Mulberry32 seeded RNG */
|
||||
export function makeRng(seed) {
|
||||
let a = seed >>> 0;
|
||||
const fn = function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
fn.getState = () => a;
|
||||
fn.setState = v => { a = v >>> 0; };
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function fmtMoney(n, signed = false) {
|
||||
const neg = n < 0;
|
||||
let v = Math.abs(Math.round(n));
|
||||
let s;
|
||||
if (v >= 1e9) s = (v / 1e9).toFixed(2) + 'B';
|
||||
else if (v >= 1e6) s = (v / 1e6).toFixed(2) + 'M';
|
||||
else s = v.toLocaleString('en-US');
|
||||
return (neg ? '-$' : (signed && n > 0 ? '+$' : '$')) + s;
|
||||
}
|
||||
export function fmtNum(n) { return Math.round(n).toLocaleString('en-US'); }
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
export function fmtDate(t) {
|
||||
return `${MONTHS[t.month]} ${t.day}, Year ${t.year}`;
|
||||
}
|
||||
export function fmtClock(t) {
|
||||
const h = Math.floor(t.hour), m = Math.floor((t.hour - h) * 60);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** pick weighted item from [[item, weight], ...] or array with .w prop */
|
||||
export function weightedPick(rng, items) {
|
||||
let total = 0;
|
||||
for (const it of items) total += (it.w !== undefined ? it.w : it[1]);
|
||||
let r = rng() * total;
|
||||
for (const it of items) {
|
||||
r -= (it.w !== undefined ? it.w : it[1]);
|
||||
if (r <= 0) return it.it !== undefined ? it.it : it[0];
|
||||
}
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
export const choice = (rng, arr) => arr[Math.floor(rng() * arr.length)];
|
||||
export const chance = (rng, p) => rng() < p;
|
||||
|
||||
/** DOM helper */
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else if (v !== null && v !== undefined) e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children.flat()) {
|
||||
if (c === null || c === undefined) continue;
|
||||
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
export function download(filename, text) {
|
||||
try {
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename;
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 400);
|
||||
return true;
|
||||
} catch (e) { console.error('download failed', e); return false; }
|
||||
}
|
||||
Reference in New Issue
Block a user