Add offline progress, tab haunting, character gallery, more flavor

Real depth additions, not a reskin of another idle game:

- applyOfflineProgress: the cult continues while the tab is closed or
  backgrounded (rAF throttles hard when hidden, so real time drifts
  well past simulated time). Approximates tick()'s per-second rates
  over the elapsed window, capped at 24h, with a narrative "while you
  were away" log line scaled to how long that was. Can end the game
  in a win or loss if the numbers would have gotten there. Re-checked
  on every tab-visibility return too, not just page load.
- Tab-title haunting: document.title cycles short eerie lines
  ("Han väntar…", "Havet kallar…") while the tab is hidden, restores
  on return.
- New late-game upgrade "Viskningen leder er" — once bought, the cult
  visits map locations on its own occasionally without a click. A
  small loss-of-agency horror beat, not just a QoL toggle.
- Log's newest line now types itself out, with characters
  occasionally swapped for occult symbols (☿☠ψ) at a rate scaled to
  current dread. Older lines stay static and legible — this never
  applies retroactively.
- New "Sällskapet" character gallery: cards for every named recurring
  character, locked to a silhouette + "???" until you've actually met
  them (checked against usedEventIds). Portraits are optional — missing
  art falls back to an initial-letter placeholder, so this works
  incrementally as art gets added. First five real portraits included.
- Real bug fix: several Kaos events referenced characters (Göran,
  Bengt-Åke, the Sydsvenskan journalist) as if already introduced,
  but had no ordering dependency on the Standard event that actually
  introduces them — so a fresh cultist could show up in the log
  already "familiar" before you'd ever met them. Gated with
  requires.completedEventId.
- More flavor text throughout: two new variants per location visit,
  6 new Standard events, 4 new Kaos events.

Evaluated three other ideas from the same source and declined them:
a generic milestone-triggered log (Djupet's log already reacts to
real state, not arbitrary point totals), an investigator combat
minigame (hemlighet draining to zero already does this job, and
tower-defense clicking would fight the game's actual tone), and a
node-sequencing ritual minigame (real scope for a twitch mechanic
that doesn't match the rest of the game's pacing).

Verified: 15/15 tests passing, clean typecheck and production build,
and a real headless-browser pass — offline catch-up after a
simulated 5-hour gap, tab-title cycling and restore, and the
character gallery rendering real portraits alongside locked cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-08-01 20:53:26 +02:00
co-authored by Claude Sonnet 5
parent 1eb9c43625
commit aeedf0abb5
14 changed files with 581 additions and 8 deletions
+107 -4
View File
@@ -1,6 +1,7 @@
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,
@@ -17,6 +18,7 @@ export function initUI(
<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>
@@ -75,6 +77,19 @@ export function initUI(
</div>
</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>
<div class="panel" id="panel-left">
<div class="panel-title">Resurser</div>
@@ -188,6 +203,15 @@ export function initUI(
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'); });
// 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%;';
@@ -203,7 +227,10 @@ export function initUI(
});
}
let latestState: State | null = null;
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`);
@@ -333,17 +360,55 @@ function renderUpgrades(state: State): void {
}
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); render as-is, scroll to top
log.innerHTML = state.log.map(e =>
`<div class="log-entry ${e.kind}">${e.text}</div>`
).join('');
// 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');
@@ -352,6 +417,44 @@ function renderLog(state: State): void {
}
}
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;
}
}
function showOverlay(state: State): void {
const ov = document.getElementById('overlay')!;
ov.classList.add('visible');