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:
co-authored by
Claude Sonnet 5
parent
1eb9c43625
commit
aeedf0abb5
@@ -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 & Ä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');
|
||||
|
||||
Reference in New Issue
Block a user