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:
BIN
public/img/characters/bengt-ake.png
Normal file
BIN
public/img/characters/bengt-ake.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/img/characters/goran.png
Normal file
BIN
public/img/characters/goran.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/img/characters/hassan.png
Normal file
BIN
public/img/characters/hassan.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/img/characters/lindqvist.png
Normal file
BIN
public/img/characters/lindqvist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/rolf.png
Normal file
BIN
public/img/characters/rolf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
86
src/characters.ts
Normal file
86
src/characters.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
bio: string;
|
||||
// The event whose id, once in usedEventIds, means you've actually met
|
||||
// this person — until then the card stays a silhouette. Undefined means
|
||||
// always known (the two founders, present from the first log line).
|
||||
unlockEventId?: string;
|
||||
img: string;
|
||||
}
|
||||
|
||||
// Order roughly matches how the game introduces people: the two founders
|
||||
// first, then everyone met along the way.
|
||||
export const CHARACTERS: Character[] = [
|
||||
{
|
||||
id: 'lindqvist',
|
||||
name: 'Broder Lindqvist',
|
||||
role: 'Grundare. Den som hittade källaren.',
|
||||
bio: 'Drömmer om havet varje natt, mer och mer bokstavligt ju längre ritualen går. Ritar samma karta om och om igen, alltid samma vinkel mot Öresund. Sover allt mindre. Säger att han inte längre behöver det.',
|
||||
img: '/img/characters/lindqvist.png',
|
||||
},
|
||||
{
|
||||
id: 'hassan',
|
||||
name: 'Syster Hassan',
|
||||
role: 'Grundare. Sköter pappersarbetet.',
|
||||
bio: 'Skriver 47-sidiga svar till Skatteverket och vinner. Praktisk, obekymrad, orubblig — tills hon börjar föreslå ändringar i ritualens ordning med en säkerhet ingen minns att hon haft förut.',
|
||||
img: '/img/characters/hassan.png',
|
||||
},
|
||||
{
|
||||
id: 'goran',
|
||||
name: 'Göran',
|
||||
role: 'Kom av misstag. Stannade av eget val.',
|
||||
bio: 'Blev tillagd i fel Discord-server och trodde ni höll på med ett brädspel om havet. Feedbacken var konstruktiv nog att han fick stanna. Numera Väktare av det Tredje Sigillet, vilket han tolkar som kassör.',
|
||||
unlockEventId: 'discord_goran',
|
||||
img: '/img/characters/goran.png',
|
||||
},
|
||||
{
|
||||
id: 'bengt_ake',
|
||||
name: 'Bengt-Åke från Husie',
|
||||
role: 'Köper en tidning om dagen. Vet mer än han säger.',
|
||||
bio: 'Köper en namnlös tidning på Pressbyrån varje dag och verkar nöjd med den. Visade sig senare vara med i en helt annan sekt sedan 1987 — vars egen sekt går tillbaka till 1840-talet. Samordningsmötet gick bra.',
|
||||
unlockEventId: 'pressbyraan',
|
||||
img: '/img/characters/bengt-ake.png',
|
||||
},
|
||||
{
|
||||
id: 'rolf',
|
||||
name: 'Rolf från Limhamn',
|
||||
role: 'Var med i tre veckor.',
|
||||
bio: 'Slutade komma. Skorna hittades utanför lokalen, ordentligt placerade, tårna mot havet. Rökarens tobak låg bredvid. Rolf rökte inte.',
|
||||
unlockEventId: 'rolf_disappears',
|
||||
img: '/img/characters/rolf.png',
|
||||
},
|
||||
{
|
||||
id: 'anneli_karin',
|
||||
name: 'Systern Anneli-Karin',
|
||||
role: 'Talar i sömnen. Inte på svenska.',
|
||||
bio: 'Talar i sömnen på ett språk ingen känner igen. Inspelningen, uppspelad baklänges, var lika obegriplig — fast på ett annat sätt.',
|
||||
unlockEventId: 'anneli_dream',
|
||||
img: '/img/characters/anneli_karin.png',
|
||||
},
|
||||
{
|
||||
id: 'sailor',
|
||||
name: 'Den gamle sjömannen',
|
||||
role: 'Vid Turning Torso. Sjunger.',
|
||||
bio: 'Pensionerad, med tatueringar ingen kan identifiera. Låste in sig i tornet vid Turning Torso och började sjunga. Räddningstjänsten kunde inte ta sig in. Sången hjälpte faktiskt ritualen.',
|
||||
unlockEventId: 'k_old_man',
|
||||
img: '/img/characters/sailor.png',
|
||||
},
|
||||
{
|
||||
id: 'researcher',
|
||||
name: 'Doktoranden',
|
||||
role: 'Översatte tre rader. Ångrade sig inte.',
|
||||
bio: 'Råkade översätta tre rader av Skriften för ett annat projekt. Sökte senare medlemskap och sa att hon "bara vill se hur det slutar." Ni tror henne inte. Ni släpper in henne ändå.',
|
||||
unlockEventId: 'k_researcher_joins',
|
||||
img: '/img/characters/researcher.png',
|
||||
},
|
||||
{
|
||||
id: 'journalist',
|
||||
name: 'Journalisten från Sydsvenskan',
|
||||
role: 'Skrev ett prisbelönt reportage. Kom tillbaka för mer.',
|
||||
bio: 'Skrev ett positivt reportage om "Malmös växande alternativa andlighet" som vann pris — juryn minns inte varför. Reste senare tillbaka för en uppföljning. Artikeln publicerades aldrig. Anteckningarna sparades.',
|
||||
unlockEventId: 'k_journalist',
|
||||
img: '/img/characters/journalist.png',
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
export const VERSION = '0.4.0';
|
||||
export const VERSION = '0.5.0';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -7,6 +7,19 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.5.0',
|
||||
date: '2026-08-01',
|
||||
changes: [
|
||||
'Källaren fortsätter utan er nu — stäng fliken eller gå därifrån, och nästa gång ni öppnar spelet har andakt, dread och kaos fortsatt sin gång (inom rimliga gränser)',
|
||||
'Fliken viskar när ni tittar bort: titeln växlar till korta rader ur ritualen så länge spelet inte är i förgrunden',
|
||||
'Sen uppgradering: Viskningen leder er — kulten besöker platser på egen hand ibland, utan att någon av er fattat beslutet',
|
||||
'Loggens senaste rad skrivs nu ut bokstav för bokstav, och tecken byts stundtals mot ockulta symboler ju djupare dread ligger — äldre rader förblir lästa som de var',
|
||||
'Ny flik: Sällskapet — porträtt och korta biografier över alla ni mött, tomma tills ni faktiskt mött dem',
|
||||
'Fixat: Göran och Bengt-Åke kunde tidigare dyka upp i loggen som redan bekanta innan ni faktiskt träffat dem',
|
||||
'Nya händelser och platsvarianter tillagda över hela spelet',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.4.0',
|
||||
date: '2026-07-13',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createState as createIntroState, startGame, tick, buyUpgrade, type State } from './engine.ts';
|
||||
import { createState as createIntroState, startGame, tick, buyUpgrade, applyOfflineProgress, type State } from './engine.ts';
|
||||
|
||||
function createState(): State {
|
||||
return startGame(createIntroState());
|
||||
@@ -201,3 +201,61 @@ describe('buyUpgrade immutability', () => {
|
||||
expect(s.andakt).toBe(frozenAndakt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOfflineProgress', () => {
|
||||
it('is a no-op under the one-minute threshold', () => {
|
||||
const s = createState();
|
||||
const after = applyOfflineProgress(s, 30_000);
|
||||
expect(after.andakt).toBe(s.andakt);
|
||||
expect(after.log.length).toBe(s.log.length);
|
||||
});
|
||||
|
||||
it('accrues andakt, dread, and inflytande roughly matching the per-second rates', () => {
|
||||
let s = createState();
|
||||
s.andaktRate = 1;
|
||||
s.troende = 2;
|
||||
s.dreadRate = 0.5;
|
||||
const before = { andakt: s.andakt, dread: s.dread };
|
||||
const after = applyOfflineProgress(s, 60 * 60 * 1000); // 1 hour
|
||||
expect(after.andakt).toBeCloseTo(before.andakt + 1 * 2 * 3600, 0);
|
||||
expect(after.dread).toBeCloseTo(before.dread + 0.5 * 3600, 0);
|
||||
});
|
||||
|
||||
it('caps the catch-up window so a long-forgotten tab does not simulate forever', () => {
|
||||
let s = createState();
|
||||
s.andaktRate = 1;
|
||||
s.troende = 1;
|
||||
// Seed identically before each call so both draw the same flavor-event
|
||||
// sequence — otherwise unseeded event selection alone (not the cap)
|
||||
// could account for a small difference in the two totals.
|
||||
seedRandom(11);
|
||||
const cappedAt24h = applyOfflineProgress(s, 24 * 3600 * 1000);
|
||||
seedRandom(11);
|
||||
const overAWeek = applyOfflineProgress(s, 30 * 24 * 3600 * 1000);
|
||||
expect(overAWeek.andakt).toBeCloseTo(cappedAt24h.andakt, 0);
|
||||
});
|
||||
|
||||
it('can end the game in a loss if hemlighet would have run out while away', () => {
|
||||
let s = createState();
|
||||
s.troende = 50; // heavy secrecy drain
|
||||
s.hemlighet = 5;
|
||||
// Kept under 2h so no flavor events roll — a lucky +hemlighet event
|
||||
// rescuing the cult is legitimate (it mirrors tick()'s own ordering),
|
||||
// just not what this test is trying to isolate.
|
||||
const after = applyOfflineProgress(s, 3600 * 1000);
|
||||
expect(after.phase).toBe('lost');
|
||||
});
|
||||
|
||||
it('never mutates the input state', () => {
|
||||
const s = createState();
|
||||
const frozenAndakt = s.andakt;
|
||||
applyOfflineProgress(s, 2 * 3600 * 1000);
|
||||
expect(s.andakt).toBe(frozenAndakt);
|
||||
});
|
||||
|
||||
it('does nothing outside the "playing" phase', () => {
|
||||
const s = createIntroState(); // phase: 'intro'
|
||||
const after = applyOfflineProgress(s, 6 * 3600 * 1000);
|
||||
expect(after).toBe(s);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface State {
|
||||
nextKaosTick: number;
|
||||
pendingEvent: PendingEvent | null;
|
||||
logSeq: number; // increments on every log push — log.length alone can't detect changes once capped at 40
|
||||
lastRealTime: number; // epoch ms, refreshed on save — used to compute offline progress on load
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
@@ -77,6 +78,7 @@ export function createState(): State {
|
||||
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
|
||||
pendingEvent: null,
|
||||
logSeq: 0,
|
||||
lastRealTime: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,6 +209,72 @@ export function tick(state: State): State {
|
||||
return s;
|
||||
}
|
||||
|
||||
const MAX_OFFLINE_SECONDS = 24 * 3600; // cap the catch-up so a week-long absence doesn't insta-win or insta-lose
|
||||
const MIN_OFFLINE_SECONDS = 60; // don't bother with a log line for a quick tab-switch
|
||||
|
||||
// Approximates what `tick()` would have done, second-by-second, without
|
||||
// actually running potentially hundreds of thousands of frames synchronously
|
||||
// on load. Uses current rates as a flat average over the away period — close
|
||||
// enough for a narrative idle game, not meant to be exact.
|
||||
export function applyOfflineProgress(state: State, awayMs: number): State {
|
||||
if (state.phase !== 'playing') return state;
|
||||
const awaySec = Math.min(MAX_OFFLINE_SECONDS, awayMs / 1000);
|
||||
if (awaySec < MIN_OFFLINE_SECONDS) return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
|
||||
s.andakt += s.andaktRate * s.troende * awaySec;
|
||||
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * awaySec;
|
||||
s.dread += s.dreadRate * awaySec;
|
||||
if (s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + s.ritualRate * awaySec);
|
||||
|
||||
const kaosScale = 1 + (s.dread / 200);
|
||||
const kaosGain = s.kaosRate * kaosScale * awaySec;
|
||||
const kaosReleased = s.kaos + kaosGain >= 100;
|
||||
s.kaos = Math.min(100, s.kaos + kaosGain);
|
||||
|
||||
const troendeDrain = s.troende > 4 ? HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) : 0;
|
||||
const dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150));
|
||||
const ritualDrain = s.ritualUnlocked ? RITUAL_HEMLIGHET_DRAIN * (s.ritual / 100) : 0;
|
||||
s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * awaySec);
|
||||
|
||||
// A little flavor for the time away — non-choice events only, since nobody
|
||||
// was there to answer. Roughly one per two hours away, capped low so the
|
||||
// log doesn't get swamped after a long absence.
|
||||
const eventRolls = Math.min(3, Math.floor(awaySec / 7200));
|
||||
for (let i = 0; i < eventRolls; i++) {
|
||||
const ev = pickEvent(STANDARD_EVENTS.filter(e => !e.choices?.length), s);
|
||||
if (!ev) break;
|
||||
s.usedEventIds.add(ev.id);
|
||||
applyEffect(s, ev.effect);
|
||||
}
|
||||
if (kaosReleased) {
|
||||
s.kaos = Math.max(0, s.kaos - 80);
|
||||
}
|
||||
|
||||
const awayLabel =
|
||||
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
|
||||
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
|
||||
'länge — för länge för att räkna';
|
||||
const summary =
|
||||
awaySec < 3600 ? `Ni var borta i ${awayLabel}. Källaren väntade, otålig.` :
|
||||
awaySec < 6 * 3600 ? `Ni var borta i ${awayLabel}. Andakten samlades utan er.` :
|
||||
awaySec < MAX_OFFLINE_SECONDS ? `Ni var borta i ${awayLabel}. Dread steg tyst i källaren medan ni sov.` :
|
||||
`Ni var borta ${awayLabel}. Källaren har fortsatt utan er. Det är oklart hur.`;
|
||||
pushLog(s, { tick: s.tick, text: summary, kind: 'system' });
|
||||
|
||||
if (s.hemlighet <= 0) {
|
||||
s.phase = 'lost';
|
||||
s.lostReason = 'Utredarna slog till medan ni var borta. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
|
||||
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
|
||||
} else if (s.ritual >= 100) {
|
||||
s.phase = 'won';
|
||||
pushLog(s, { tick: s.tick, text: 'Det är gjort — utan er i rummet. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
export function resolveEventChoice(state: State, choiceIndex: number): State {
|
||||
if (!state.pendingEvent) return state;
|
||||
const choice = state.pendingEvent.event.choices?.[choiceIndex];
|
||||
@@ -243,6 +311,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
|
||||
'Någon har städat källaren medan ingen var där. Ingen av er erkänner sig skyldig. +andakt',
|
||||
'Den varma stolen är varm igen. Ingen har suttit i den. +andakt',
|
||||
'Hyresvärden har inte hört av sig på tre månader. Hyran dras ändå, varje månad, på utsatt dag. +andakt',
|
||||
'Det droppar från taket på exakt samma ställe som förra veckan. Golvet under är torrt. +andakt',
|
||||
],
|
||||
effect: { andakt: 15 },
|
||||
requiresArea: 'rosengard',
|
||||
@@ -253,6 +323,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
|
||||
'Kritan på asfalten har inte suddats trots regnet i natt. +dread +ritual',
|
||||
'Torget är tomt förutom en duva som går i exakt cirkelns mönster. +dread +ritual',
|
||||
'Spårvagnen bromsar mjukt precis vid cirkelns kant, varje gång, utan att föraren verkar märka det. +dread +ritual',
|
||||
'Någon har lagt blommor mitt i cirkeln. Det står inget namn på kortet. Datumet är nästa vecka. +dread +ritual',
|
||||
],
|
||||
effect: { dread: 8, ritual: 2 },
|
||||
requiresArea: 'varnhem',
|
||||
@@ -263,6 +335,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
|
||||
'Ett fönster högt uppe är tänt som det aldrig varit förut. Ingen bor där. Ingen har någonsin bott där. +dread +ritual',
|
||||
'Torso vrider sig en grad till i vinden som inte finns. +dread +ritual',
|
||||
'Hissarna stannar på en våning som inte finns i receptionens skylt. Dörrarna öppnas ändå. Ingen går ut. +dread +ritual',
|
||||
'Fasadens glas reflekterar en himmel som inte matchar den du står under. +dread +ritual',
|
||||
],
|
||||
effect: { dread: 15, ritual: 5 },
|
||||
requiresArea: 'torso',
|
||||
@@ -273,6 +347,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
|
||||
'En färja passerar utan ljud. Ingen ombord vinkar. Alla ombord tittar rakt på dig. +dread',
|
||||
'Bron surrar på en frekvens du känner i tänderna snarare än öronen. +dread',
|
||||
'Fiskarna vid stranden ligger stilla i vattenbrynet, andas fortfarande, väntar på något. +dread',
|
||||
'Tullstationens skärmar visar samma bil passera om och om igen, med olika registreringsskyltar. +dread',
|
||||
],
|
||||
effect: { dread: 5 },
|
||||
lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.',
|
||||
@@ -282,6 +358,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
|
||||
'Alla änderna vänder sig samtidigt mot Turning Torso när du kommer. +andakt',
|
||||
'Sanden är kall trots solen. Ingen annan verkar märka det. +andakt',
|
||||
'Kallbadhusets bastu är på trots att ingen bokat den. Dörren är varm att röra vid, ingen sitter inne. +andakt',
|
||||
'En strandpromenerare frågar om vägen till en adress som inte finns i Malmö. Du visar honom ändå. +andakt',
|
||||
],
|
||||
effect: { andakt: 8 },
|
||||
lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.',
|
||||
@@ -291,6 +369,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
|
||||
'Kaklet från 1923 är kallt att röra vid, oavsett årstid. +andakt',
|
||||
'Grannen genom väggen sjunger något som nästan, men inte riktigt, är en psalm. +andakt',
|
||||
'Elmätaren i trapphuset räknar baklänges. Elbolaget har inte hört av sig om det. +andakt',
|
||||
'En tidigare hyresgästs post kommer fortfarande. Adressaten flyttade ut 1971. +andakt',
|
||||
],
|
||||
effect: { andakt: 10 },
|
||||
lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.',
|
||||
@@ -300,12 +380,16 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
|
||||
'Statyn på torget har flyttat en centimeter sedan i förra veckan. Du är den enda som mätt. +kaos',
|
||||
'Alla klockor på torget visar olika tid. Ingen av dem är fel. +kaos',
|
||||
'Ett gatumusikkorband spelar samma stycke de spelade förra gången du var här. De säger att de aldrig spelat det förut. +kaos',
|
||||
'Duvorna flyger i en cirkel över torget som inte sluter sig, om och om igen, tills du slutar titta. +kaos',
|
||||
],
|
||||
effect: { kaos: 8 },
|
||||
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
|
||||
},
|
||||
};
|
||||
|
||||
export const VISITABLE_LOCATION_IDS = Object.keys(LOCATION_VISITS);
|
||||
|
||||
const VISIT_COOLDOWN_TICKS = 900; // 30s at 30fps
|
||||
|
||||
export function visitLocation(state: State, id: string, cooldowns: Map<string, number>): { state: State; cooldowns: Map<string, number> } {
|
||||
@@ -360,6 +444,10 @@ export function loadState(): State | null {
|
||||
usedEventIds: new Set<string>(d.usedEventIds ?? []),
|
||||
flags: new Set<string>(d.flags ?? []),
|
||||
logSeq: d.logSeq ?? 0,
|
||||
// Saves from before offline progress existed won't have this — treat
|
||||
// them as "just now" so an old save doesn't suddenly claim you were
|
||||
// away since the Unix epoch.
|
||||
lastRealTime: d.lastRealTime ?? Date.now(),
|
||||
} as State;
|
||||
} catch (_) {
|
||||
return null;
|
||||
|
||||
@@ -691,6 +691,58 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'library_card',
|
||||
text: 'Stadsbiblioteket har skickat en påminnelse om en bok ingen av er lånat. Titeln går inte att läsa högt utan att stamma. Förfallodatumet var för tre år sedan.',
|
||||
effect: { dread: 6, kaos: 4 },
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: 'malmo_taxi',
|
||||
text: 'En taxichaufför känner igen er utan att ni sagt vart ni ska och kör direkt till källaren. Han tar ingen betalning. Han ber om något annat istället, artigt, som om det vore normalt.',
|
||||
effect: { dread: 10, andakt: 10 },
|
||||
weight: 6,
|
||||
requires: { minTroende: 3 },
|
||||
},
|
||||
{
|
||||
id: 'weather_wrong',
|
||||
text: 'SMHI:s prognos för Malmö visar regn. Solen skiner. Appen uppdateras inte, oavsett hur många gånger ni drar ner den.',
|
||||
effect: { kaos: 10 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'apartment_silence',
|
||||
text: 'Grannarna ovanför källaren har inte hörts på en vecka. Posten samlas i brevlådan. Ingen av er går och knackar.',
|
||||
effect: { dread: 8, hemlighet: -4 },
|
||||
weight: 6,
|
||||
requires: { minDread: 20 },
|
||||
},
|
||||
{
|
||||
id: 'choice_lost_child',
|
||||
text: 'Ett barn kommer in i källaren genom en dörr ingen av er lämnat olåst och frågar efter sin mamma. Barnet verkar inte rädd. Det verkar snarare besviken över att träffa er istället.',
|
||||
effect: {},
|
||||
weight: 5,
|
||||
requires: { minDread: 60 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Följ barnet ut och leta efter mamman',
|
||||
effect: { hemlighet: 5, kaos: -10 },
|
||||
resultText: 'Ni går ut tillsammans. Gatan är tom. Barnet släpper er hand utan ett ord och går åt ett håll ni inte följer.',
|
||||
},
|
||||
{
|
||||
text: 'Fråga vad hon heter',
|
||||
effect: { dread: 12, ritual: 3 },
|
||||
resultText: 'Barnet säger ett namn ingen av er känner igen, men alla skriver ner exakt likadant utan att jämföra.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'malmo_arena_concert',
|
||||
text: 'En utsåld konsert på Malmö Arena tystnar mitt i en låt, i exakt tolv sekunder, samtidigt som ert möte. Ingen i publiken verkar märka det. Klippet på nätet visar tystnaden. Ljudet finns kvar i videon om man sänker volymen till max.',
|
||||
effect: { kaos: 12, dread: 8 },
|
||||
weight: 5,
|
||||
requires: { minTroende: 4 },
|
||||
},
|
||||
];
|
||||
|
||||
// ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
|
||||
@@ -701,6 +753,7 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantandet. Tvärtom. Bamse leder nu det andra kvartalets böner.',
|
||||
effect: { kaos: -40, andakt: 20, troende: 1 },
|
||||
weight: 10,
|
||||
requires: { completedEventId: 'discord_goran' },
|
||||
},
|
||||
{
|
||||
id: 'k_influencer',
|
||||
@@ -765,6 +818,7 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
text: 'Göran har frågat om han kan "ta lite mer ansvar" i föreningen. Vi har utnämnt honom till Väktare av det Tredje Sigillet. Han tolkar det som kassör. Det är ungefär rätt.',
|
||||
effect: { kaos: -30, andakt: 30, hemlighet: 5 },
|
||||
weight: 8,
|
||||
requires: { completedEventId: 'discord_goran' },
|
||||
},
|
||||
{
|
||||
id: 'k_no_survivors',
|
||||
@@ -778,6 +832,7 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
text: 'Bengt-Åke (tidningsmannen från Husie) visade sig vara med i en helt annan sekt sedan 1987. Hans sekt visade sig vara med i en ännu äldre sekt sedan 1840-talet. Vi håller ett samordningsmöte. Det är produktivt.',
|
||||
effect: { kaos: -45, andakt: 35, troende: 2 },
|
||||
weight: 7,
|
||||
requires: { completedEventId: 'pressbyraan' },
|
||||
},
|
||||
{
|
||||
id: 'k_skinwalker_bus',
|
||||
@@ -798,7 +853,7 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
text: 'Journalisten från Sydsvenskan som skrev det prisbelönta reportaget har rest till Malmö för en uppföljningsartikel. Han hittade mer än han letade efter. Artikeln publiceras aldrig. Vi sparar hans anteckningar.',
|
||||
effect: { kaos: -40, hemlighet: 20, ritual: 5 },
|
||||
weight: 6,
|
||||
requires: { minDread: 45 },
|
||||
requires: { minDread: 45, completedEventId: 'k_journalist' },
|
||||
},
|
||||
{
|
||||
id: 'k_kult_archon',
|
||||
@@ -895,4 +950,31 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
weight: 3,
|
||||
requires: { minDread: 220 },
|
||||
},
|
||||
{
|
||||
id: 'k_mall_muzak',
|
||||
text: 'Bakgrundsmusiken i Triangeln har bytts ut mot en enda ackordföljd som upprepas i sex timmar utan paus. Vaktbolaget säger att de inte kan hitta strömbrytaren. Kunderna handlar mer än vanligt.',
|
||||
effect: { kaos: -40, andakt: 25 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'k_ferry_returns',
|
||||
text: 'En färja som togs ur trafik 2019 lade till vid hamnen i natt, i tid och enligt en tidtabell som inte längre existerar. Ingen gick av. Den lade ut igen innan personalen hann kontrollera den.',
|
||||
effect: { kaos: -55, dread: 40 },
|
||||
weight: 5,
|
||||
requires: { minDread: 50 },
|
||||
},
|
||||
{
|
||||
id: 'k_choir_practice',
|
||||
text: 'Malmö Kammarkör har börjat repetera ett stycke ingen av dem minns att de fått noter till. Dirigenten säger att det "kändes rätt att göra." Konserten är redan fullbokad.',
|
||||
effect: { kaos: -35, ritual: 4, andakt: 15 },
|
||||
weight: 7,
|
||||
requires: { minRitual: 10 },
|
||||
},
|
||||
{
|
||||
id: 'k_construction_halt',
|
||||
text: 'Ett byggprojekt vid Västra Hamnen har stannat av utan förklaring. Arbetarna säger att marken "kändes fel" på ett sätt ingen av dem kunde beskriva för facket. Platsen står orörd sedan dess.',
|
||||
effect: { kaos: -40, dread: 30, hemlighet: 10 },
|
||||
weight: 6,
|
||||
requires: { minDread: 35 },
|
||||
},
|
||||
];
|
||||
|
||||
60
src/main.ts
60
src/main.ts
@@ -1,9 +1,16 @@
|
||||
import './style.css';
|
||||
import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave } from './engine.ts';
|
||||
import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave, applyOfflineProgress, VISITABLE_LOCATION_IDS } from './engine.ts';
|
||||
import { initUI, renderState, setPausedUI } from './ui.ts';
|
||||
import { initMap, updateMap } 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;
|
||||
@@ -12,6 +19,38 @@ 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')!;
|
||||
|
||||
@@ -46,11 +85,30 @@ function loop(now: number): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +494,82 @@ body {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#characters-modal-inner { width: min(860px, 94vw); }
|
||||
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.character-portrait {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 5;
|
||||
background: #08080f;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.character-portrait img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: grayscale(0.15) contrast(1.05);
|
||||
}
|
||||
|
||||
.character-portrait-fallback,
|
||||
.locked-portrait {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: var(--dim);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.character-card.locked .character-portrait.locked-portrait {
|
||||
background: repeating-linear-gradient(135deg, #08080f, #08080f 6px, #0d0d1a 6px, #0d0d1a 12px);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.character-card.locked .character-name,
|
||||
.character-card.locked .character-role {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.character-role {
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.character-bio {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.help-changelog-entry {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
111
src/ui.ts
111
src/ui.ts
@@ -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');
|
||||
|
||||
@@ -119,6 +119,15 @@ export const UPGRADES: Upgrade[] = [
|
||||
available: s => s.upgrades.has('grand_ritual_prep'),
|
||||
once: false,
|
||||
},
|
||||
{
|
||||
id: 'whisper_guidance',
|
||||
name: 'Viskningen leder er',
|
||||
desc: 'Ni behöver inte längre besluta vart ni går. Fötterna vet vägen innan huvudet gör det. Bekvämt. Det är också första gången ingen av er är helt säker på vem som bestämmer längre.',
|
||||
cost: () => ({ andakt: 600, dread: 80 }),
|
||||
effect: () => { /* handled by main.ts's tick loop — see WHISPER_INTERVAL_TICKS */ },
|
||||
available: s => s.upgrades.has('torso_beacon') && !s.upgrades.has('whisper_guidance'),
|
||||
once: true,
|
||||
},
|
||||
];
|
||||
|
||||
function ritualDeepenings(s: State): number {
|
||||
|
||||
Reference in New Issue
Block a user