import type { State } from './engine.ts'; import { UPGRADES } from './upgrades.ts'; import { VERSION, CHANGELOG } from './data/version.ts'; import { CHARACTERS } from './characters.ts'; export function initUI( app: HTMLElement, onUpgrade: (id: string) => void, onRestart: () => void, onEventChoice: (index: number) => void, onStart: () => void, onAbandon: () => void, onTogglePause: () => void, ): void { app.innerHTML = `
DJUPET
Malmö, Sverige

Nio stolar i en fuktig källare i Rosengård. En av dem är alltid lite för varm.
Ni har hört sången i väggarna. Den Sovande har inte hört er ännu.
Det är dags att ändra på det.

Resurser
Troende
1
Andakt
0
Inflytande
0
Dread
0
Hemlighet
100
Kaos
0
Åtgärder
Ritualens kärnplatser
○ Rosengård
○ Värnhemstorget
○ Turning Torso
Övriga platser
Øresundsbron
Ribersborgsstranden
Davidshallsgatan
Stortorget
Utredarna
Inaktiva
`; // Overlays live as siblings of #app, appended directly to , not // inside it. #app carries a `filter` (for the dread color effect), // and CSS filter on an ancestor creates a new containing block for any // `position: fixed` descendant — so these would clip to #app's own box // (max-width: 1200px, centered) instead of the real viewport. On wider // screens that left a dead margin on each side where "click outside to // close" silently did nothing, because the click never actually landed // inside the fixed element's clipped box. document.body.insertAdjacentHTML('beforeend', `
DJUPET — Hjälp & Ändringslogg
Hur man spelar
  • Andakt ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.
  • Dread ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.
  • Hemlighet sjunker när ni blir för uppenbara. Når den noll är det slut.
  • Kaos byggs upp naturligt och utlöser kaotiska händelser när det når 100.
  • Ritual låses upp via Skriften. Nå 100 för att fullborda ceremonin.
  • Klicka på platser på kartan för att besöka dem. Varje besök lämnar spår.
  • Händelser inträffar automatiskt. De flesta är dåliga. En del är värre.
Om spelet

Djupet är ett idle-spel om en kult i Malmö, Sverige.
Inspirerat av Anders Fager, Kult och Call of Cthulhu.
Den Sovande är inte vaken ännu. Jobba på det.

Ändringslogg
${CHANGELOG.map(e => `
v${e.version} ${e.date}
    ${e.changes.map(c => `
  • ${c}
  • `).join('')}
`).join('')}
Sällskapet

De som suttit vid bordet. Vissa längre än andra.

`); document.getElementById('overlay-btn')!.addEventListener('click', onRestart); document.getElementById('start-btn')!.addEventListener('click', onStart); document.getElementById('abandon-btn')!.addEventListener('click', () => { if (window.confirm('Överge kulten? Cirkeln bryts och ni går skilda vägar.')) onAbandon(); }); document.getElementById('pause-btn')!.addEventListener('click', onTogglePause); document.getElementById('event-modal-choices')!.addEventListener('click', e => { const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null; if (btn?.dataset['choice'] !== undefined) onEventChoice(Number(btn.dataset['choice'])); }); const helpModal = document.getElementById('help-modal')!; const helpInner = document.getElementById('help-modal-inner')!; document.getElementById('help-btn')!.addEventListener('click', () => helpModal.classList.toggle('visible')); document.getElementById('help-close')!.addEventListener('click', () => helpModal.classList.remove('visible')); helpModal.addEventListener('click', (e) => { if (!helpInner.contains(e.target as Node)) helpModal.classList.remove('visible'); }); document.addEventListener('keydown', (e) => { if (e.key === '?') helpModal.classList.toggle('visible'); }); const charactersModal = document.getElementById('characters-modal')!; const charactersInner = document.getElementById('characters-modal-inner')!; document.getElementById('characters-btn')!.addEventListener('click', () => { renderCharacterGrid(latestState); charactersModal.classList.toggle('visible'); }); document.getElementById('characters-close')!.addEventListener('click', () => charactersModal.classList.remove('visible')); charactersModal.addEventListener('click', (e) => { if (!charactersInner.contains(e.target as Node)) charactersModal.classList.remove('visible'); }); document.querySelectorAll('.archive-toggle-btn').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('.archive-toggle-btn').forEach((b) => b.classList.remove('active')); btn.classList.add('active'); archiveMode = btn.dataset['archive'] === 'full'; document.getElementById('characters-hint')!.textContent = archiveMode ? 'Fullständigt arkiv — alla porträtt, oavsett hur långt ni kommit. Klicka för att se dem i fullstorlek.' : 'De som suttit vid bordet. Vissa längre än andra.'; renderCharacterGrid(latestState); }); }); const lightbox = document.getElementById('lightbox')!; const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement; const lightboxCaption = document.getElementById('lightbox-caption')!; const lightboxBlurb = document.getElementById('lightbox-blurb')!; document.getElementById('characters-grid')!.addEventListener('click', (e) => { const portrait = (e.target as Element).closest('.character-card:not(.locked) .character-portrait'); if (!portrait) return; const card = portrait.closest('[data-char]'); const img = portrait.querySelector('img'); if (!card || !img?.src) return; lightboxImg.src = img.src; lightboxCaption.textContent = card.querySelector('.character-name')?.textContent ?? ''; lightboxBlurb.textContent = ''; lightbox.classList.add('visible'); }); document.getElementById('lightbox-close')!.addEventListener('click', () => lightbox.classList.remove('visible')); lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('visible'); }); // Escape closes whichever overlay is topmost — lightbox sits on top of // the characters modal, which can sit on top of help, so check in that // order and stop at the first one actually open. document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (lightbox.classList.contains('visible')) { lightbox.classList.remove('visible'); return; } if (charactersModal.classList.contains('visible')) { charactersModal.classList.remove('visible'); return; } if (helpModal.classList.contains('visible')) { helpModal.classList.remove('visible'); return; } }); // Split the map container: map on top, log below const mapContainer = document.getElementById('map-container')!; mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;'; const mapEl = document.getElementById('map')!; mapEl.style.cssText = 'flex:0 0 45%;min-height:0;max-height:45%;'; const logEl = document.getElementById('log')!; logEl.style.cssText = 'flex:1 1 0;overflow-y:auto;border-top:1px solid var(--border);min-height:0;'; // Upgrade click delegation document.getElementById('upgrades')!.addEventListener('click', e => { const btn = (e.target as Element).closest('[data-upgrade]') as HTMLElement | null; if (btn?.dataset['upgrade']) onUpgrade(btn.dataset['upgrade']); }); } let latestState: State | null = null; let archiveMode = false; export function renderState(state: State): void { latestState = state; setText('r-troende', String(state.troende)); setText('r-andakt', fmt(state.andakt)); setText('r-andakt-rate', `+${(state.andaktRate * state.troende).toFixed(1)}/s`); setText('r-inflytande', fmt(state.inflytande)); setText('r-dread', fmt(state.dread)); setText('r-dread-rate', state.dreadRate > 0 ? `+${state.dreadRate.toFixed(1)}/s` : ''); setText('r-hemlighet', Math.floor(state.hemlighet).toString()); setText('r-kaos', Math.floor(state.kaos).toString()); setBar('bar-hemlighet', state.hemlighet); setBar('bar-kaos', state.kaos); // Ambient visual reactivity — the deeper the dread, the more the world tilts; low hemlighet bleeds red at the edges. const root = document.documentElement.style; root.setProperty('--dread-intensity', Math.min(1, state.dread / 150).toFixed(3)); root.setProperty('--hemlighet-danger', (state.hemlighet < 40 ? (1 - state.hemlighet / 40) * 0.35 : 0).toFixed(3)); const ritualSection = document.getElementById('ritual-section')!; if (state.ritualUnlocked) { ritualSection.style.display = ''; setText('r-ritual', Math.floor(state.ritual).toString()); setBar('bar-ritual', state.ritual); } // Phase tag const phaseEl = document.getElementById('phase-tag')!; if (state.ritual >= 50) { phaseEl.textContent = 'Sent skede'; phaseEl.className = 'phase-tag late'; } else if (state.dread >= 30) { phaseEl.textContent = 'Mitt skede'; phaseEl.className = 'phase-tag mid'; } else { phaseEl.textContent = 'Tidigt skede'; phaseEl.className = 'phase-tag early'; } // Areas setArea('area-rosengard', state.areas.rosengard, 'Rosengård'); setArea('area-varnhem', state.areas.varnhem, 'Värnhemstorget'); setArea('area-torso', state.areas.torso, 'Turning Torso'); // Investigator status const inv = document.getElementById('investigator-status')!; if (state.hemlighet < 20) { inv.textContent = 'Nära — väldigt nära'; inv.style.color = 'var(--danger)'; } else if (state.hemlighet < 50) { inv.textContent = 'Misstänksamma'; inv.style.color = 'var(--kaos)'; } else { inv.textContent = 'Inaktiva'; inv.style.color = 'var(--safe)'; } // Upgrades renderUpgrades(state); // Log renderLog(state); // Intro screen document.getElementById('intro-screen')!.classList.toggle('visible', state.phase === 'intro'); // Overlay const overlay = document.getElementById('overlay')!; if (state.phase === 'won' || state.phase === 'lost' || state.phase === 'abandoned') { showOverlay(state); } else { overlay.classList.remove('visible'); } // Event choice modal renderEventModal(state); } let lastPendingEventId: string | null = null; function renderEventModal(state: State): void { const modal = document.getElementById('event-modal')!; const pending = state.pendingEvent; const id = pending?.event.id ?? null; if (id === lastPendingEventId) return; lastPendingEventId = id; if (!pending) { modal.classList.remove('visible'); return; } document.getElementById('event-modal-text')!.textContent = pending.event.text; const choices = document.getElementById('event-modal-choices')!; choices.innerHTML = (pending.event.choices ?? []).map((c, i) => `` ).join(''); modal.classList.add('visible'); } let lastUpgradeKey = ''; function renderUpgrades(state: State): void { const container = document.getElementById('upgrades')!; const available = UPGRADES.filter(u => u.available(state) && (u.once ? !state.upgrades.has(u.id) : true)); // Only rebuild DOM when availability or affordability changes const key = available.map(u => { const cost = u.cost(state); const canAfford = (!cost.andakt || state.andakt >= cost.andakt) && (!cost.dread || state.dread >= cost.dread) && (!cost.inflytande || state.inflytande >= cost.inflytande); return `${u.id}:${canAfford}:${cost.andakt ?? ''}`; }).join('|'); if (key === lastUpgradeKey) return; lastUpgradeKey = key; if (!available.length) { container.innerHTML = '
Inga tillgängliga åtgärder.
'; return; } container.innerHTML = available.map(u => { const cost = u.cost(state); const canAfford = (!cost.andakt || state.andakt >= cost.andakt) && (!cost.dread || state.dread >= cost.dread) && (!cost.inflytande || state.inflytande >= cost.inflytande); const costs = [ cost.andakt ? `${cost.andakt} andakt` : '', cost.dread ? `${cost.dread} dread` : '', cost.inflytande ? `${cost.inflytande} inflytande` : '', ].filter(Boolean).join(', '); return ``; }).join(''); } let lastLogSeq = -1; let typewriterTimer: number | undefined; // Occult stand-ins for the typewriter effect on the newest log line — a // deeper ritual means a shakier grip on plain Swedish. Never applied // retroactively to older entries, so history stays legible. const GLITCH_CHARS = ['☿', '☠', 'ψ', 'Ψ', '⛧', '҂']; function glitchChar(dreadIntensity: number): string | null { const chance = Math.min(0.18, dreadIntensity * 0.22); if (Math.random() < chance) return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)]!; return null; } function renderLog(state: State): void { const log = document.getElementById('log')!; if (state.logSeq === lastLogSeq) return; const isFirstRender = lastLogSeq === -1; lastLogSeq = state.logSeq; // state.log is newest-first (unshift). The newest line gets its own // element so it can type itself out; everything else renders plainly. // It has to come FIRST in the DOM to match scrollTop = 0 below — it // previously came last, which put the newest entry below the fold, // scrolled out of view until enough later entries pushed it up. const [newest, ...rest] = state.log; log.innerHTML = (newest ? `
` : '') + rest.map(e => `
${e.text}
`).join(''); log.scrollTop = 0; if (typewriterTimer !== undefined) { window.clearInterval(typewriterTimer); typewriterTimer = undefined; } if (newest) { const el = document.getElementById('log-newest')!; // Only narrative beats (story events, kaos) get the typewriter — the // player's own actions (upgrades, visits) confirm instantly. Animating // those too made the log feel like it was lagging a full action behind: // the reveal took 1-2s, and if you acted again before it finished, the // old line would silently snap to full text right as the new one // started typing, which read exactly like "nothing happened until the // next event." const animate = !isFirstRender && state.phase === 'playing' && (newest.kind === 'event' || newest.kind === 'kaos'); if (!animate) { el.textContent = newest.text; } else { const dreadIntensity = Math.min(1, state.dread / 150); const text = newest.text; const stepMs = text.length > 90 ? 4 : 8; let i = 0; typewriterTimer = window.setInterval(() => { i += 1; el.textContent = text.slice(0, i).split('').map(ch => (ch === ' ' ? ch : glitchChar(dreadIntensity) ?? ch)).join(''); if (i >= text.length) { window.clearInterval(typewriterTimer); typewriterTimer = undefined; } }, stepMs); } } if (state.log[0]?.kind === 'kaos') { const app = document.getElementById('app')!; app.classList.remove('kaos-glitch'); void app.offsetWidth; // restart animation app.classList.add('kaos-glitch'); } } function renderCharacterGrid(state: State | null): void { const grid = document.getElementById('characters-grid')!; const met = new Set(state?.usedEventIds ?? []); grid.innerHTML = CHARACTERS.map(c => { const known = archiveMode || !c.unlockEventId || met.has(c.unlockEventId); if (!known) { return `
?
???
Inte mött än.
`; } return `
${c.name}
${c.name}
${c.role}
${c.bio}
`; }).join(''); // Portraits are optional — art may not exist yet for every character. // Fall back to an initial-letter placeholder rather than a broken image. for (const c of CHARACTERS) { const card = grid.querySelector(`[data-char="${c.id}"]`); const img = card?.querySelector('img'); if (!img) continue; img.addEventListener('error', () => { const fallback = document.createElement('div'); fallback.className = 'character-portrait-fallback'; fallback.textContent = c.name.charAt(0); img.replaceWith(fallback); }, { once: true }); img.src = c.img; } } function showOverlay(state: State): void { const ov = document.getElementById('overlay')!; ov.classList.add('visible'); const title = document.getElementById('overlay-title')!; const body = document.getElementById('overlay-body')!; if (state.phase === 'won') { ov.style.backgroundImage = "url('/img/win.webp')"; title.textContent = 'FULLBORDAT'; title.className = 'overlay-title won'; body.textContent = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.'; } else if (state.phase === 'abandoned') { ov.style.backgroundImage = "url('/img/fail.webp')"; title.textContent = 'ÖVERGIVET'; title.className = 'overlay-title lost'; body.textContent = 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.'; } else { ov.style.backgroundImage = "url('/img/fail.webp')"; title.textContent = 'AVSLÖJAD'; title.className = 'overlay-title lost'; body.textContent = state.lostReason ?? 'Utredarna kom.'; } } // Called on every map marker click, alongside the actual visit — a way to // see the location's art at a readable size instead of the 40px tooltip // thumbnail. Probes the image first so a location with no art yet doesn't // pop an empty modal; the visit itself always happens regardless. export function showLocationArt(id: string, label: string, blurb: string): void { const src = `/img/locations/${id}.png`; const probe = new Image(); probe.onload = () => { const lightbox = document.getElementById('lightbox')!; const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement; const lightboxCaption = document.getElementById('lightbox-caption')!; const lightboxBlurb = document.getElementById('lightbox-blurb')!; lightboxImg.src = src; lightboxCaption.textContent = label; lightboxBlurb.textContent = blurb; lightbox.classList.add('visible'); }; probe.src = src; } export function setPausedUI(paused: boolean): void { const btn = document.getElementById('pause-btn')!; btn.textContent = paused ? 'Fortsätt' : 'Pausa'; document.getElementById('app')!.classList.toggle('paused', paused); setText('pause-tag', paused ? '⏸ Pausad' : ''); } function setText(id: string, val: string): void { const el = document.getElementById(id); if (el) el.textContent = val; } function setBar(id: string, pct: number): void { const el = document.getElementById(id) as HTMLElement | null; if (el) el.style.width = `${Math.min(100, Math.max(0, pct))}%`; } function setArea(id: string, active: boolean, label: string): void { const el = document.getElementById(id); if (el) { el.textContent = `${active ? '●' : '○'} ${label}`; el.style.color = active ? 'var(--dread)' : 'var(--dim)'; } } function fmt(n: number): string { return Math.floor(n).toLocaleString('sv-SE'); }