// ============ 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 }); }