Files
djupet/src/main.ts
Fredrik Johansson 1aa9eb2ce8 Click a map marker to see its art at full size
Reuses the lightbox already built for the character gallery. Clicking
any location still triggers the visit as before, but now also probes
for that location's art and, if it exists, pops it up full-size with
a caption — no modal for locations that don't have art yet, the visit
still happens either way.

Also added a standing "about this place" blurb per location (map.ts's
new LOCATION_INFO), shown under the art. This is distinct from
LOCATION_VISITS' randomized per-visit outcome text in engine.ts, which
still changes every time — the blurb is a fixed description of the
place itself, not what just happened there.

Verified via headless browser: clicking Turning Torso's marker pops
the lightbox with its real art, label, and blurb, no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:42:28 +02:00

122 lines
4.5 KiB
TypeScript

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, showLocationArt } from './ui.ts';
import { initMap, updateMap, LOCATION_INFO } 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<string, number>();
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);
const info = LOCATION_INFO[locationId];
if (info) showLocationArt(locationId, info.label, info.blurb);
});
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();