import './style.css'; import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave, applyOfflineProgress, VISITABLE_LOCATION_IDS } from './engine.ts'; import { initUI, renderState, setPausedUI } from './ui.ts'; import { initMap, updateMap } from './map.ts'; let state = loadState() ?? createState(); // The cult doesn't pause just because the tab isn't in front of you — // catch up on what happened while you were gone, once, on load. if (state.phase === 'playing') { state = applyOfflineProgress(state, Date.now() - (state.lastRealTime ?? Date.now())); } let visitCooldowns = new Map(); let paused = false; let lastTime = 0; const TARGET_FPS = 30; const FRAME_MS = 1000 / TARGET_FPS; const SAVE_INTERVAL_MS = 10_000; let lastSaveTime = 0; const WHISPER_INTERVAL_TICKS = 30 * 45; // 45s at 30fps const WHISPER_RETRY_TICKS = 30 * 5; // retry sooner if every location was on cooldown let nextWhisperTick = 0; const BASE_TITLE = document.title; const HAUNT_MESSAGES = ['Han väntar…', 'Vakna honom…', 'Havet kallar…', 'Kom tillbaka…', '☿ Källaren minns ☿']; let hauntTimer: number | undefined; let hauntIndex = 0; document.addEventListener('visibilitychange', () => { if (document.hidden) { if (state.phase !== 'playing') return; hauntIndex = 0; hauntTimer = window.setInterval(() => { document.title = HAUNT_MESSAGES[hauntIndex % HAUNT_MESSAGES.length]!; hauntIndex += 1; }, 2500); } else { if (hauntTimer !== undefined) { window.clearInterval(hauntTimer); hauntTimer = undefined; } document.title = BASE_TITLE; // Coming back to the tab is itself a kind of "return" — re-check for // offline progress in case the tab was merely backgrounded, not // reloaded (requestAnimationFrame throttles hard in hidden tabs, so // real time can drift a long way ahead of simulated game time). if (state.phase === 'playing') { state = applyOfflineProgress(state, Date.now() - (state.lastRealTime ?? Date.now())); state = { ...state, lastRealTime: Date.now() }; renderState(state); } } }); function start(): void { const app = document.getElementById('app')!; initUI(app, (id) => { state = buyUpgrade(state, id); renderState(state); saveState(state); }, () => { clearSave(); state = createState(); visitCooldowns = new Map(); document.getElementById('overlay')!.classList.remove('visible'); renderState(state); }, (index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); }, () => { state = startGame(state); renderState(state); saveState(state); }, () => { state = abandonGame(state); renderState(state); saveState(state); }, () => { paused = !paused; setPausedUI(paused); } ); initMap('map', (locationId) => { const result = visitLocation(state, locationId, visitCooldowns); state = result.state; visitCooldowns = result.cooldowns; renderState(state); }); renderState(state); requestAnimationFrame(loop); } function loop(now: number): void { if (now - lastTime >= FRAME_MS) { lastTime = now; if (state.phase === 'playing' && !paused) { state = tick(state); // "Viskningen leder er" — once purchased, the cult moves on its own // occasionally without a click. Skip a location currently on // cooldown rather than burning the whole interval on a no-op visit. if (state.upgrades.has('whisper_guidance') && state.tick >= nextWhisperTick) { const tickNow = state.tick; const available = VISITABLE_LOCATION_IDS.filter(id => (visitCooldowns.get(id) ?? 0) <= tickNow); if (available.length) { const id = available[Math.floor(Math.random() * available.length)]!; const result = visitLocation(state, id, visitCooldowns); state = result.state; visitCooldowns = result.cooldowns; nextWhisperTick = state.tick + WHISPER_INTERVAL_TICKS; } else { nextWhisperTick = state.tick + WHISPER_RETRY_TICKS; } } renderState(state); updateMap(state.dread, state.areas); } if (now - lastSaveTime >= SAVE_INTERVAL_MS) { lastSaveTime = now; state = { ...state, lastRealTime: Date.now() }; saveState(state); } } requestAnimationFrame(loop); } start();