Files
djupet/src/ui.ts
T

509 lines
21 KiB
TypeScript
Raw Normal View History

2026-07-07 23:05:22 +02:00
import type { State } from './engine.ts';
import { UPGRADES } from './upgrades.ts';
import { VERSION, CHANGELOG } from './data/version.ts';
import { CHARACTERS } from './characters.ts';
2026-07-07 23:05:22 +02:00
export function initUI(
app: HTMLElement,
onUpgrade: (id: string) => void,
onRestart: () => void,
onEventChoice: (index: number) => void,
onStart: () => void,
onAbandon: () => void,
onTogglePause: () => void,
): void {
2026-07-07 23:05:22 +02:00
app.innerHTML = `
<div id="header">
<div class="brand">DJUPET</div>
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span><span id="pause-tag" class="pause-tag"></span></div>
<div class="header-actions">
<button id="pause-btn" class="help-btn" title="Pausa spelet">Pausa</button>
<button id="characters-btn" class="help-btn" title="Sällskapet">Sällskapet</button>
<button id="abandon-btn" class="help-btn" title="Överge kulten">Avbryt</button>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
</div>
</div>
<div id="intro-screen" style="background-image:url('/img/intro.webp')">
<div class="intro-scrim">
<div class="intro-title">DJUPET</div>
<div class="intro-sub">Malmö, Sverige</div>
<p class="intro-flavor">
Nio stolar i en fuktig källare i Rosengård. En av dem är alltid lite för varm.<br>
Ni har hört sången i väggarna. Den Sovande har inte hört er ännu.<br>
Det är dags att ändra på det.
</p>
<button class="overlay-btn" id="start-btn">Börja ritualen</button>
</div>
</div>
<div id="help-modal" class="help-modal">
<div class="help-modal-inner" id="help-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">DJUPET — Hjälp &amp; Ändringslogg</span>
<button id="help-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<section class="help-section">
<div class="help-section-title">Hur man spelar</div>
<ul class="help-list">
<li><strong>Andakt</strong> ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.</li>
<li><strong>Dread</strong> ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.</li>
<li><strong>Hemlighet</strong> sjunker när ni blir för uppenbara. Når den noll är det slut.</li>
<li><strong>Kaos</strong> byggs upp naturligt och utlöser kaotiska händelser när det når 100.</li>
<li><strong>Ritual</strong> låses upp via Skriften. Nå 100 för att fullborda ceremonin.</li>
<li>Klicka på <em>platser på kartan</em> för att besöka dem. Varje besök lämnar spår.</li>
<li>Händelser inträffar automatiskt. De flesta är dåliga. En del är värre.</li>
</ul>
</section>
<section class="help-section">
<div class="help-section-title">Om spelet</div>
<p class="help-flavor">
Djupet är ett idle-spel om en kult i Malmö, Sverige.<br>
Inspirerat av Anders Fager, Kult och Call of Cthulhu.<br>
Den Sovande är inte vaken ännu. Jobba på det.
</p>
</section>
<section class="help-section">
<div class="help-section-title">Ändringslogg</div>
${CHANGELOG.map(e => `
<div class="help-changelog-entry">
<div class="help-changelog-version">v${e.version} <span class="help-changelog-date">${e.date}</span></div>
<ul class="help-list">${e.changes.map(c => `<li>${c}</li>`).join('')}</ul>
</div>
`).join('')}
</section>
</div>
</div>
2026-07-07 23:05:22 +02:00
</div>
<div id="characters-modal" class="help-modal">
<div class="help-modal-inner" id="characters-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">Sällskapet</span>
<button id="characters-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<p class="help-flavor" style="margin-bottom:14px;">De som suttit vid bordet. Vissa längre än andra.</p>
<div id="characters-grid" class="characters-grid"></div>
</div>
</div>
</div>
2026-07-07 23:05:22 +02:00
<div class="panel" id="panel-left">
<div class="panel-title">Resurser</div>
<div class="resource">
<div class="resource-label">Troende</div>
<div class="resource-value" id="r-troende">1</div>
</div>
<div class="resource">
<div class="resource-label">Andakt <span id="r-andakt-rate" style="color:var(--dim);font-size:10px;"></span></div>
<div class="resource-value" id="r-andakt">0</div>
</div>
<div class="resource">
<div class="resource-label">Inflytande</div>
<div class="resource-value" id="r-inflytande">0</div>
</div>
2026-07-07 23:05:22 +02:00
<div class="resource">
<div class="resource-label">Dread <span id="r-dread-rate" style="color:var(--dread);font-size:10px;"></span></div>
<div class="resource-value" style="color:var(--dread);" id="r-dread">0</div>
</div>
<div class="resource bar-hemlighet" style="margin-top:16px;">
<div class="resource-label">Hemlighet</div>
<div class="resource-value" id="r-hemlighet">100</div>
<div class="resource-bar"><div class="resource-bar-fill" id="bar-hemlighet" style="width:100%"></div></div>
</div>
<div class="resource bar-kaos">
<div class="resource-label">Kaos</div>
<div class="resource-value" id="r-kaos">0</div>
<div class="resource-bar"><div class="resource-bar-fill" id="bar-kaos" style="width:0%"></div></div>
</div>
<div class="resource bar-ritual" id="ritual-section" style="display:none;">
<div class="resource-label">Ritual</div>
<div class="resource-value" style="color:var(--ritual);" id="r-ritual">0</div>
<div class="resource-bar"><div class="resource-bar-fill" id="bar-ritual" style="width:0%"></div></div>
</div>
<div style="margin-top:20px;">
<div class="panel-title">Åtgärder</div>
<div id="upgrades"></div>
</div>
</div>
<div id="map-container">
<div id="map"></div>
<div id="log"></div>
</div>
<div class="panel" id="panel-right">
<div class="panel-title">Ritualens kärnplatser</div>
2026-07-07 23:05:22 +02:00
<div id="areas" style="font-size:11px;color:var(--dim);line-height:2;">
<div id="area-rosengard">○ Rosengård</div>
<div id="area-varnhem">○ Värnhemstorget</div>
<div id="area-torso">○ Turning Torso</div>
</div>
<div style="margin-top:16px;">
<div class="panel-title">Övriga platser</div>
<div style="font-size:11px;color:var(--dim);line-height:2;">
<div>Øresundsbron</div>
<div>Ribersborgsstranden</div>
<div>Davidshallsgatan</div>
<div>Stortorget</div>
</div>
</div>
2026-07-07 23:05:22 +02:00
<div style="margin-top:20px;" id="investigator-section">
<div class="panel-title">Utredarna</div>
<div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</div>
</div>
</div>
<div id="overlay">
<div class="overlay-scrim">
<div class="overlay-title" id="overlay-title"></div>
<div class="overlay-body" id="overlay-body"></div>
<button class="overlay-btn" id="overlay-btn">Börja om</button>
</div>
2026-07-07 23:05:22 +02:00
</div>
<div id="danger-vignette"></div>
<div id="event-modal" class="help-modal">
<div class="help-modal-inner" id="event-modal-inner">
<div class="help-modal-body">
<div class="event-modal-text" id="event-modal-text"></div>
<div class="event-modal-choices" id="event-modal-choices"></div>
</div>
</div>
</div>
2026-07-07 23:05:22 +02:00
`;
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);
2026-07-07 23:05:22 +02:00
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 === '?' || (e.key === 'Escape' && helpModal.classList.contains('visible'))) 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'); });
2026-07-07 23:05:22 +02:00
// 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%;';
2026-07-07 23:05:22 +02:00
const mapEl = document.getElementById('map')!;
mapEl.style.cssText = 'flex:0 0 45%;min-height:0;max-height:45%;';
2026-07-07 23:05:22 +02:00
const logEl = document.getElementById('log')!;
logEl.style.cssText = 'flex:1 1 0;overflow-y:auto;border-top:1px solid var(--border);min-height:0;';
2026-07-07 23:05:22 +02:00
// 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;
2026-07-07 23:05:22 +02:00
export function renderState(state: State): void {
latestState = state;
2026-07-07 23:05:22 +02:00
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));
2026-07-07 23:05:22 +02:00
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));
2026-07-07 23:05:22 +02:00
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)';
2026-07-07 23:05:22 +02:00
} 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');
2026-07-07 23:05:22 +02:00
// 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) =>
`<button class="upgrade-btn" data-choice="${i}"><span class="up-name">${c.text}</span></button>`
).join('');
modal.classList.add('visible');
2026-07-07 23:05:22 +02:00
}
let lastUpgradeKey = '';
2026-07-07 23:05:22 +02:00
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;
2026-07-07 23:05:22 +02:00
if (!available.length) {
container.innerHTML = '<div style="font-size:11px;color:var(--dim);">Inga tillgängliga åtgärder.</div>';
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);
2026-07-07 23:05:22 +02:00
const costs = [
cost.andakt ? `${cost.andakt} andakt` : '',
cost.dread ? `${cost.dread} dread` : '',
cost.inflytande ? `${cost.inflytande} inflytande` : '',
2026-07-07 23:05:22 +02:00
].filter(Boolean).join(', ');
return `<button class="upgrade-btn" data-upgrade="${u.id}" ${canAfford ? '' : 'disabled'}>
<span class="up-name">${u.name}</span>
<span class="up-desc">${u.desc}</span>
<span class="up-cost">Kostnad: ${costs || '—'}</span>
</button>`;
}).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;
}
2026-07-07 23:05:22 +02:00
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.
const [newest, ...rest] = state.log;
log.innerHTML = rest.map(e => `<div class="log-entry ${e.kind}">${e.text}</div>`).join('')
+ (newest ? `<div class="log-entry ${newest.kind}" id="log-newest"></div>` : '');
log.scrollTop = 0;
if (typewriterTimer !== undefined) { window.clearInterval(typewriterTimer); typewriterTimer = undefined; }
if (newest) {
const el = document.getElementById('log-newest')!;
if (isFirstRender || state.phase !== 'playing') {
// Don't animate the save you just loaded, or the ending screen's line.
el.textContent = newest.text;
} else {
const dreadIntensity = Math.min(1, state.dread / 150);
const text = newest.text;
const stepMs = text.length > 120 ? 8 : 18;
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');
}
2026-07-07 23:05:22 +02:00
}
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 = !c.unlockEventId || met.has(c.unlockEventId);
if (!known) {
return `
<div class="character-card locked">
<div class="character-portrait locked-portrait">?</div>
<div class="character-name">???</div>
<div class="character-role">Inte mött än.</div>
</div>`;
}
return `
<div class="character-card" data-char="${c.id}">
<div class="character-portrait"><img alt="${c.name}" loading="lazy"></div>
<div class="character-name">${c.name}</div>
<div class="character-role">${c.role}</div>
<div class="character-bio">${c.bio}</div>
</div>`;
}).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;
}
}
2026-07-07 23:05:22 +02:00
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')";
2026-07-07 23:05:22 +02:00
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.';
2026-07-07 23:05:22 +02:00
} else {
ov.style.backgroundImage = "url('/img/fail.webp')";
2026-07-07 23:05:22 +02:00
title.textContent = 'AVSLÖJAD';
title.className = 'overlay-title lost';
body.textContent = state.lostReason ?? 'Utredarna kom.';
}
}
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' : '');
}
2026-07-07 23:05:22 +02:00
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');
}