Compare commits
19 Commits
0efbe9d55b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a741a7bfc | ||
|
|
b610d27542 | ||
|
|
1aa9eb2ce8 | ||
|
|
d2a8b71ffc | ||
|
|
899ab72c80 | ||
|
|
aeedf0abb5 | ||
|
|
1eb9c43625 | ||
|
|
ee60a9f4d2 | ||
|
|
bde809e862 | ||
|
|
323a61997f | ||
|
|
b7c052a11f | ||
|
|
4b4e5c14aa | ||
|
|
efc19bac1d | ||
|
|
d039dfb019 | ||
|
|
4d47409e03 | ||
|
|
10c7999701 | ||
|
|
6ed9d2dac3 | ||
|
|
80fdebf102 | ||
|
|
b9561b05a5 |
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
.env.example
|
||||
*.log
|
||||
data
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Djupet</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
</head>
|
||||
|
||||
5
public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0d0d1a"/>
|
||||
<path d="M9 10 L16 24 L23 10" fill="none" stroke="#aa66dd" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="24" r="1.6" fill="#aa66dd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
BIN
public/img/characters/anneli-karin.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/bengt-ake.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/img/characters/goran.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/img/characters/hassan.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/img/characters/journalist.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/lindqvist.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/researcher.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/rolf.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/img/characters/sailor.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/img/fail.webp
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/img/intro.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/img/locations/davidshall.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/img/locations/mollan.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/img/locations/oresund.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/img/locations/ribersborg.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/img/locations/rosengard.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/img/locations/stortorget.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/img/locations/torso.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/img/locations/universitet.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/img/locations/varnhem.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/img/win.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
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.3.0';
|
||||
export const VERSION = '0.7.0';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -7,6 +7,51 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.7.0',
|
||||
date: '2026-08-01',
|
||||
changes: [
|
||||
'Klicka på en plats på kartan för att se dess bild i fullstorlek, tillsammans med en fast beskrivning av platsen',
|
||||
'Alla 9 platser har nu konst på kartan',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.6.0',
|
||||
date: '2026-08-01',
|
||||
changes: [
|
||||
'Alla porträtt i Sällskapet är nu på plats',
|
||||
'Nytt läge i Sällskapet: Fullständigt arkiv — se alla porträtt oavsett hur långt ni kommit, klicka för fullstorlek',
|
||||
'Platser på kartan visar nu en liten bild i förhandsvisningen (när konst finns för platsen)',
|
||||
'Två nya platser: Möllevångstorget och Malmö universitet, båda besökbara direkt från start',
|
||||
'Fler händelser tillagda, inklusive ett fotografi från 1930-talet som inte borde finnas',
|
||||
],
|
||||
},
|
||||
{
|
||||
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',
|
||||
changes: [
|
||||
'Vissa händelser ställer nu ett val istället för att bara hända — vad ni svarar Broder Lindqvist spelar roll',
|
||||
'Syster Hassan och Broder Lindqvist har fått egna, flerstegs historielinjer som reagerar på tidigare val',
|
||||
'Nya platsbesök: varje plats har nu flera varianter av vad som möter er, inte bara en fast rad text',
|
||||
'Skärmen reagerar nu på tillståndet i källaren — högre dread färgar allt, låg hemlighet blöder rött i kanterna, kaos-händelser skakar bilden',
|
||||
'Ny introskärm innan ritualen börjar, med egen bakgrund',
|
||||
'Seger och nederlag har nu egen bakgrundskonst istället för bara text på svart',
|
||||
'Ny knapp: Avbryt — överge kulten i förtid om ni ångrar er',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.3.0',
|
||||
date: '2026-07-08',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createState, 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());
|
||||
}
|
||||
import { UPGRADES } from './upgrades.ts';
|
||||
|
||||
// Deterministic PRNG so simulation tests aren't flaky, but still exercise
|
||||
@@ -132,7 +136,7 @@ describe('deepen_ritual cost scaling', () => {
|
||||
it('grows monotonically with each purchase, giving andakt a late-game sink', () => {
|
||||
let s = createState();
|
||||
s.upgrades.add('grand_ritual_prep');
|
||||
s.ritualRate = 2.5; // baseline after torso_beacon + grand_ritual_prep
|
||||
s.ritualRate = 0.5; // baseline after torso_beacon (0.15) + grand_ritual_prep (0.35)
|
||||
s.andakt = 1_000_000;
|
||||
s.dread = 1_000_000;
|
||||
|
||||
@@ -197,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);
|
||||
});
|
||||
});
|
||||
|
||||
264
src/engine.ts
@@ -1,4 +1,9 @@
|
||||
import { STANDARD_EVENTS, KAOS_EVENTS, type GameEvent } from './events.ts';
|
||||
|
||||
export interface PendingEvent {
|
||||
event: GameEvent;
|
||||
kind: 'event' | 'kaos';
|
||||
}
|
||||
import { UPGRADES } from './upgrades.ts';
|
||||
|
||||
export interface State {
|
||||
@@ -19,11 +24,15 @@ export interface State {
|
||||
areas: { rosengard: boolean; varnhem: boolean; torso: boolean };
|
||||
upgrades: Set<string>;
|
||||
log: LogEntry[];
|
||||
phase: 'playing' | 'won' | 'lost';
|
||||
phase: 'intro' | 'playing' | 'won' | 'lost' | 'abandoned';
|
||||
lostReason?: string;
|
||||
usedEventIds: Set<string>;
|
||||
flags: Set<string>;
|
||||
nextEventTick: number;
|
||||
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 {
|
||||
@@ -62,13 +71,29 @@ export function createState(): State {
|
||||
areas: { rosengard: false, varnhem: false, torso: false },
|
||||
upgrades: new Set(),
|
||||
log: [{ tick: 0, text: 'Mötet började i en fuktig källare i Rosengård. Nio stolar. En av dem är alltid lite för varm.', kind: 'system' }],
|
||||
phase: 'playing',
|
||||
phase: 'intro',
|
||||
usedEventIds: new Set(),
|
||||
flags: new Set(),
|
||||
nextEventTick: EVENT_INTERVAL_TICKS,
|
||||
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
|
||||
pendingEvent: null,
|
||||
logSeq: 0,
|
||||
lastRealTime: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function startGame(state: State): State {
|
||||
if (state.phase !== 'intro') return state;
|
||||
return { ...state, phase: 'playing' };
|
||||
}
|
||||
|
||||
export function abandonGame(state: State): State {
|
||||
const s = { ...state, log: [...state.log] };
|
||||
s.phase = 'abandoned';
|
||||
pushLog(s, { tick: s.tick, text: 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.', kind: 'system' });
|
||||
return s;
|
||||
}
|
||||
|
||||
function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
|
||||
const available = pool.filter(e => {
|
||||
if (state.usedEventIds.has(e.id)) return false;
|
||||
@@ -78,6 +103,8 @@ function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
|
||||
if (r.minDread && state.dread < r.minDread) return false;
|
||||
if (r.minRitual && state.ritual < r.minRitual) return false;
|
||||
if (r.unlockedArea && !state.areas[r.unlockedArea]) return false;
|
||||
if (r.flag && !state.flags.has(r.flag)) return false;
|
||||
if (r.completedEventId && !state.usedEventIds.has(r.completedEventId)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!available.length) return null;
|
||||
@@ -90,6 +117,12 @@ function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
|
||||
return available[available.length - 1];
|
||||
}
|
||||
|
||||
function pushLog(state: State, entry: LogEntry): void {
|
||||
state.log.unshift(entry);
|
||||
state.logSeq += 1;
|
||||
if (state.log.length > 40) state.log = state.log.slice(0, 40);
|
||||
}
|
||||
|
||||
function applyEffect(state: State, effect: GameEvent['effect']): void {
|
||||
if (effect.andakt) state.andakt = Math.max(0, state.andakt + effect.andakt);
|
||||
if (effect.hemlighet) state.hemlighet = Math.max(0, Math.min(100, state.hemlighet + effect.hemlighet));
|
||||
@@ -104,9 +137,15 @@ const DT = 1 / 30; // 30fps
|
||||
export function tick(state: State): State {
|
||||
if (state.phase !== 'playing') return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||
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.tick += 1;
|
||||
|
||||
// A choice event is awaiting the player's answer — freeze new events, keep resources flowing.
|
||||
if (s.pendingEvent) {
|
||||
s.nextEventTick += 1;
|
||||
s.nextKaosTick += 1;
|
||||
}
|
||||
|
||||
// Resource accumulation
|
||||
s.andakt += s.andaktRate * s.troende * DT;
|
||||
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * DT;
|
||||
@@ -124,45 +163,143 @@ export function tick(state: State): State {
|
||||
s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * DT);
|
||||
|
||||
// Standard event
|
||||
if (s.tick >= s.nextEventTick) {
|
||||
if (!s.pendingEvent && s.tick >= s.nextEventTick) {
|
||||
const ev = pickEvent(STANDARD_EVENTS, s);
|
||||
if (ev) {
|
||||
s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' });
|
||||
applyEffect(s, ev.effect);
|
||||
s.usedEventIds.add(ev.id);
|
||||
if (ev.choices?.length) {
|
||||
pushLog(s, { tick: s.tick, text: ev.text, kind: 'event' });
|
||||
s.pendingEvent = { event: ev, kind: 'event' };
|
||||
} else {
|
||||
pushLog(s, { tick: s.tick, text: ev.text, kind: 'event' });
|
||||
applyEffect(s, ev.effect);
|
||||
}
|
||||
}
|
||||
s.nextEventTick = s.tick + EVENT_INTERVAL_TICKS + Math.floor(Math.random() * 60);
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
}
|
||||
|
||||
// Kaos event
|
||||
if (s.kaos >= KAOS_THRESHOLD || s.tick >= s.nextKaosTick) {
|
||||
if (!s.pendingEvent && (s.kaos >= KAOS_THRESHOLD || s.tick >= s.nextKaosTick)) {
|
||||
const ev = pickEvent(KAOS_EVENTS, s);
|
||||
if (ev) {
|
||||
s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' });
|
||||
// Kaos events are chaos-relief flavor, not narrative progression — let them recur
|
||||
// over a long game instead of permanently depleting a pool of only ~24 entries.
|
||||
if (ev.choices?.length) {
|
||||
pushLog(s, { tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' });
|
||||
s.pendingEvent = { event: ev, kind: 'kaos' };
|
||||
} else {
|
||||
pushLog(s, { tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' });
|
||||
applyEffect(s, ev.effect);
|
||||
s.usedEventIds.add(ev.id);
|
||||
}
|
||||
}
|
||||
s.kaos = Math.max(0, s.kaos - 80);
|
||||
s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120);
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
}
|
||||
|
||||
// Win / lose checks
|
||||
if (s.ritual >= 100) {
|
||||
s.phase = 'won';
|
||||
s.log.unshift({ tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
|
||||
pushLog(s, { tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
|
||||
} else if (s.hemlighet <= 0) {
|
||||
s.phase = 'lost';
|
||||
s.lostReason = 'Utredarna slog till vid gryningen. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
|
||||
s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' });
|
||||
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
|
||||
}
|
||||
|
||||
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];
|
||||
if (!choice) 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) };
|
||||
applyEffect(s, choice.effect);
|
||||
if (choice.flag) s.flags.add(choice.flag);
|
||||
pushLog(s, { tick: s.tick, text: `↳ ${choice.resultText}`, kind: s.pendingEvent!.kind });
|
||||
s.pendingEvent = null;
|
||||
|
||||
if (s.hemlighet <= 0) {
|
||||
s.phase = 'lost';
|
||||
s.lostReason = 'Utredarna slog till vid gryningen. 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. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
interface LocationVisit {
|
||||
text: string;
|
||||
texts: string[];
|
||||
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
|
||||
requiresArea?: keyof State['areas'];
|
||||
lockedText: string;
|
||||
@@ -170,45 +307,111 @@ interface LocationVisit {
|
||||
|
||||
const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
rosengard: {
|
||||
text: 'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
|
||||
texts: [
|
||||
'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',
|
||||
lockedText: 'En fuktig källarlokal i Rosengård. Du kan se ljuset under dörren. Ni är inte redo än.',
|
||||
},
|
||||
varnhem: {
|
||||
text: 'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
|
||||
texts: [
|
||||
'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',
|
||||
lockedText: 'Värnhemstorget ser normalt ut. Det är ett dåligt tecken.',
|
||||
},
|
||||
torso: {
|
||||
text: 'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
|
||||
texts: [
|
||||
'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',
|
||||
lockedText: 'Turning Torso vrider sig i vinden. Inte i vinden. I något annat.',
|
||||
},
|
||||
oresund: {
|
||||
text: 'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
|
||||
texts: [
|
||||
'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.',
|
||||
},
|
||||
ribersborg: {
|
||||
text: 'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
|
||||
texts: [
|
||||
'Ä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.',
|
||||
},
|
||||
davidshall: {
|
||||
text: 'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
|
||||
texts: [
|
||||
'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.',
|
||||
},
|
||||
stortorget: {
|
||||
text: 'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
|
||||
texts: [
|
||||
'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.',
|
||||
},
|
||||
mollan: {
|
||||
texts: [
|
||||
'Marknadsstånden på Möllan säljer samma fisk som alltid, men ingen minns vilket hav den kom från. Den smakar rätt ändå. +kaos',
|
||||
'Ett gäng ungdomar sjunger något på ett språk som låter som tre olika språk samtidigt, i perfekt harmoni. +kaos',
|
||||
'Fontänen på torget rinner uppåt i en sekund innan den rättar till sig. Ingen annan såg det. Du är säker på att du såg det. +kaos',
|
||||
'En gatuförsäljare erbjuder en frukt du inte känner igen, från ett land ingen kan namnge. Den är söt. Den är för söt. +kaos',
|
||||
'Graffitin på muren mot innergården ändras varje gång du vänder ryggen till, alltid samma motiv, aldrig samma vinkel. +kaos',
|
||||
],
|
||||
effect: { kaos: 10 },
|
||||
lockedText: 'Möllevångstorget är fullt av liv, musik, dofter från femtio kök samtidigt. Det är svårt att höra sig själv tänka här. Det är kanske poängen.',
|
||||
},
|
||||
universitet: {
|
||||
texts: [
|
||||
'Biblioteket har ett rum för sällan efterfrågade samlingar. Din kod fungerar där ingen minns att du fått en. +andakt +dread',
|
||||
'En föreläsningssal står tom men whiteboarden är full av ekvationer ingen lärare skrivit. Den sista raden är inte matematik. +andakt +dread',
|
||||
'Doktoranderna på plan tre pratar lägre än vanligt när du går förbi. En av dem tittar upp för länge. +andakt +dread',
|
||||
'Bibliotekets sökkatalog listar en avhandling om Skriften, publicerad om tre år. Ingen kan checka ut den förrän dess. +andakt +dread',
|
||||
'Studenthälsan har en anmärkningsvärt hög andel patienter med sömnproblem i just den här korridoren. Ingen har satt det i samband med något. +andakt +dread',
|
||||
],
|
||||
effect: { andakt: 12, dread: 6 },
|
||||
lockedText: 'Malmö universitet vid Niagara. Glas och stål och lysrör som surrar på en frekvens du inte borde kunna höra genom väggarna.',
|
||||
},
|
||||
};
|
||||
|
||||
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> } {
|
||||
@@ -218,10 +421,10 @@ export function visitLocation(state: State, id: string, cooldowns: Map<string, n
|
||||
const now = state.tick;
|
||||
if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns };
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
|
||||
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
|
||||
const text = areaLocked ? visit.lockedText : visit.text;
|
||||
const text = areaLocked ? visit.lockedText : visit.texts[Math.floor(Math.random() * visit.texts.length)];
|
||||
|
||||
if (!areaLocked) {
|
||||
if (visit.effect.andakt) s.andakt += visit.effect.andakt;
|
||||
@@ -231,8 +434,7 @@ export function visitLocation(state: State, id: string, cooldowns: Map<string, n
|
||||
if (visit.effect.hemlighet) s.hemlighet = Math.max(0, Math.min(100, s.hemlighet + visit.effect.hemlighet));
|
||||
}
|
||||
|
||||
s.log.unshift({ tick: now, text: `⬡ ${text}`, kind: 'system' });
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
pushLog(s, { tick: now, text: `⬡ ${text}`, kind: 'system' });
|
||||
|
||||
const newCooldowns = new Map(cooldowns);
|
||||
newCooldowns.set(id, now + VISIT_COOLDOWN_TICKS);
|
||||
@@ -246,6 +448,7 @@ export function saveState(state: State): void {
|
||||
...state,
|
||||
upgrades: [...state.upgrades],
|
||||
usedEventIds: [...state.usedEventIds],
|
||||
flags: [...state.flags],
|
||||
log: state.log.slice(0, 20),
|
||||
};
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(serial));
|
||||
@@ -261,6 +464,12 @@ export function loadState(): State | null {
|
||||
...d,
|
||||
upgrades: new Set<string>(d.upgrades ?? []),
|
||||
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;
|
||||
@@ -280,13 +489,12 @@ export function buyUpgrade(state: State, id: string): State {
|
||||
if (cost.dread && state.dread < cost.dread) return state;
|
||||
if (cost.inflytande && state.inflytande < cost.inflytande) return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
if (cost.andakt) s.andakt -= cost.andakt;
|
||||
if (cost.dread) s.dread -= cost.dread;
|
||||
if (cost.inflytande) s.inflytande -= cost.inflytande;
|
||||
upgrade.effect(s);
|
||||
if (upgrade.once) s.upgrades.add(id);
|
||||
s.log.unshift({ tick: s.tick, text: `↳ ${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
pushLog(s, { tick: s.tick, text: `↳ ${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
|
||||
return s;
|
||||
}
|
||||
|
||||
478
src/events.ts
@@ -7,12 +7,27 @@ export type EventEffect = Partial<{
|
||||
ritual: number;
|
||||
}>;
|
||||
|
||||
export interface EventChoice {
|
||||
text: string;
|
||||
effect: EventEffect;
|
||||
resultText: string;
|
||||
flag?: string;
|
||||
}
|
||||
|
||||
export interface GameEvent {
|
||||
id: string;
|
||||
text: string;
|
||||
effect: EventEffect;
|
||||
weight: number;
|
||||
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>;
|
||||
requires?: Partial<{
|
||||
minTroende: number;
|
||||
minDread: number;
|
||||
minRitual: number;
|
||||
unlockedArea: Area;
|
||||
flag: string;
|
||||
completedEventId: string;
|
||||
}>;
|
||||
choices?: EventChoice[];
|
||||
}
|
||||
|
||||
export type Area = 'rosengard' | 'varnhem' | 'torso';
|
||||
@@ -157,7 +172,7 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
// ── Anders Fager-register: vardagssvenska möter det ofattbara ──
|
||||
{
|
||||
id: 'ica_maxi',
|
||||
text: 'Kassörskan på ICA Maxi vid Fosie har inte blinkmat på tre veckor. Kunden bakom i kön räknade. Ingen anmälde det. Det är Malmö.',
|
||||
text: 'Kassörskan på ICA Maxi vid Fosie har inte blinkat på tre veckor. Kunden bakom i kön räknade. Ingen anmälde det. Det är Malmö.',
|
||||
effect: { dread: 7, kaos: 4 },
|
||||
weight: 9,
|
||||
},
|
||||
@@ -442,6 +457,357 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
weight: 6,
|
||||
requires: { minDread: 30 },
|
||||
},
|
||||
|
||||
// ── Choice events ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'choice_lindqvist_dream',
|
||||
text: 'Broder Lindqvist vaknar mitt i mötet och säger att fisken har gett honom en instruktion. Han vägrar upprepa den högt. Han vill viska den till en av er, en och en.',
|
||||
effect: {},
|
||||
weight: 9,
|
||||
choices: [
|
||||
{
|
||||
text: 'Lyssna på viskningen',
|
||||
effect: { dread: 12, hemlighet: -4, andakt: 6 },
|
||||
resultText: 'Ni lyssnar. Det ni hör går inte att glömma. Ni vet nu var Den Sovande vänder blicken härnäst.',
|
||||
flag: 'lindqvist_listened',
|
||||
},
|
||||
{
|
||||
text: 'Be honom tiga',
|
||||
effect: { kaos: 6 },
|
||||
resultText: 'Ni tystar honom. Han ler ändå, som om han redan sagt det som behövdes.',
|
||||
flag: 'lindqvist_silenced',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lindqvist_chain_2a',
|
||||
text: 'Broder Lindqvist har inte sovit på fyra dagar. Han säger att han inte längre behöver det. Han ritar samma karta om och om igen, alltid samma vinkel mot Öresund.',
|
||||
effect: { dread: 10, andakt: 5 },
|
||||
weight: 9,
|
||||
requires: { flag: 'lindqvist_listened', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
|
||||
},
|
||||
{
|
||||
id: 'lindqvist_chain_2b',
|
||||
text: 'Broder Lindqvist har blivit tystlåten sedan ni bad honom tiga. Han deltar fortfarande, men han skriver nu ner sina drömmar istället för att dela dem. Ingen har läst anteckningsboken. Ingen vågar fråga.',
|
||||
effect: { hemlighet: -6, kaos: 4 },
|
||||
weight: 9,
|
||||
requires: { flag: 'lindqvist_silenced', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
|
||||
},
|
||||
{
|
||||
id: 'lindqvist_chain_3',
|
||||
text: 'Lindqvist ber att få gå ner till Öresund ensam, på natten, "för att svara ordentligt denna gång." Han har redan sina kläder på sig. Han väntar bara på ert tillstånd.',
|
||||
effect: {},
|
||||
weight: 8,
|
||||
requires: { minDread: 45, completedEventId: 'choice_lindqvist_dream' },
|
||||
choices: [
|
||||
{
|
||||
text: 'Låt honom gå',
|
||||
effect: { dread: 20, ritual: 4, hemlighet: -8 },
|
||||
resultText: 'Han går ner mot vattnet och försvinner in i dimman. Han kommer tillbaka i gryningen, torr, leende, och pratar inte längre om drömmar. Han pratar inte om något alls.',
|
||||
},
|
||||
{
|
||||
text: 'Håll fast honom',
|
||||
effect: { kaos: 15, andakt: 10 },
|
||||
resultText: 'Ni håller kvar honom till gryningen. Han skriker att ni har gjort ett misstag. Han slutar skrika när solen går upp. Han säger aldrig varför.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'choice_skatteverket_audit',
|
||||
text: 'Skatteverket har utökat sin utredning. En handläggare vid namn Persson vill besöka källaren personligen "för att förstå verksamhetens karaktär."',
|
||||
effect: {},
|
||||
weight: 8,
|
||||
choices: [
|
||||
{
|
||||
text: 'Bjud in honom',
|
||||
effect: { hemlighet: -15, andakt: 25 },
|
||||
resultText: 'Persson dricker teet ni bjuder på. Han går därifrån med ett leende och ett obegripligt formulär ifyllt till er fördel. Han minns inte riktigt vägen hem.',
|
||||
},
|
||||
{
|
||||
text: 'Avböj artigt',
|
||||
effect: { hemlighet: 5, andakt: -5 },
|
||||
resultText: 'Ni skickar ett artigt nej. Persson accepterar det, men skriver något i sina anteckningar som ni inte får se.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hassan_ambition',
|
||||
text: 'Syster Hassan har börjat föreslå ändringar i ritualens ordning. Hon säger att hon sett en bättre väg i en dröm. Hon säger det med en säkerhet ni inte kommer ihåg att hon haft förut.',
|
||||
effect: {},
|
||||
weight: 8,
|
||||
requires: { minDread: 15 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Ge henne ansvar',
|
||||
effect: { andakt: 12, dread: 6 },
|
||||
resultText: 'Ni låter henne leda nästa moment. Cirkeln följer henne lättare än den någonsin följt er. Det känns bra. Det känns också som en förlust ni inte kan namnge.',
|
||||
flag: 'hassan_empowered',
|
||||
},
|
||||
{
|
||||
text: 'Behåll ordningen',
|
||||
effect: { kaos: 5 },
|
||||
resultText: 'Ni tackar för förslaget och fortsätter som förut. Hassan nickar och säger inget mer. Hon skriver fortfarande i sin anteckningsbok efteråt.',
|
||||
flag: 'hassan_sidelined',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hassan_chain_2a',
|
||||
text: 'Syster Hassan leder nu halva mötena. Hon har egna ord för Den Sovande, ord ingen av er lärt henne. Troende lyssnar på henne som de en gång lyssnade på er.',
|
||||
effect: { andakt: 15, kaos: 5 },
|
||||
weight: 8,
|
||||
requires: { flag: 'hassan_empowered', completedEventId: 'hassan_ambition', minDread: 35 },
|
||||
},
|
||||
{
|
||||
id: 'hassan_chain_2b',
|
||||
text: 'Syster Hassans anteckningsbok är full nu. Hon slutar be om lov att tala. Det märks inte i mötena — hon sitter tyst och rak — men ni ser henne titta på dörren mot Värnhemstorget på ett nytt sätt.',
|
||||
effect: { hemlighet: -5, dread: 8 },
|
||||
weight: 8,
|
||||
requires: { flag: 'hassan_sidelined', completedEventId: 'hassan_ambition', minDread: 35 },
|
||||
},
|
||||
{
|
||||
id: 'hassan_chain_3',
|
||||
text: 'Syster Hassan ber om att få genomföra en egen ritual, separat från er, på Turning Torso. Hon säger att Den Sovande bett om henne specifikt. Hon säger det utan att blinka.',
|
||||
effect: {},
|
||||
weight: 7,
|
||||
requires: { minDread: 55, completedEventId: 'hassan_ambition', unlockedArea: 'torso' },
|
||||
choices: [
|
||||
{
|
||||
text: 'Låt henne göra det',
|
||||
effect: { dread: 22, ritual: 6, troende: -1 },
|
||||
resultText: 'Hon går upp ensam i mörkret. Ni ser ljus i fönstren på våningar som inte har fönster. Hon kommer tillbaka annorlunda — mer er, mindre henne, i en riktning ingen kan sätta ord på.',
|
||||
},
|
||||
{
|
||||
text: 'Neka henne',
|
||||
effect: { kaos: 18, hemlighet: -4 },
|
||||
resultText: 'Ni säger nej. Hon accepterar det med ett leende som inte når hennes ögon och går tillbaka till sin plats i cirkeln. Ni undrar länge efteråt om ni gjorde rätt val, eller bara det säkra.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'choice_new_recruit',
|
||||
text: 'En ung kvinna har suttit utanför källaren tre kvällar i rad. Hon säger att hon hörde sången genom väggarna och att hon inte kan sluta tänka på den.',
|
||||
effect: {},
|
||||
weight: 9,
|
||||
choices: [
|
||||
{
|
||||
text: 'Släpp in henne',
|
||||
effect: { troende: 1, kaos: 8, hemlighet: -3 },
|
||||
resultText: 'Hon kliver in och sätter sig utan att bli tillsagd var. Cirkeln känns mindre ensam. Den känns också mer sedd.',
|
||||
},
|
||||
{
|
||||
text: 'Skicka bort henne',
|
||||
effect: { hemlighet: 3, dread: 4 },
|
||||
resultText: 'Ni ber henne gå. Hon nickar och försvinner in i mörkret. Ni hör sången fortsätta, en röst kort.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'choice_ikea_table_ritual',
|
||||
text: 'Det blödande LACK-bordet har slutat blöda. Istället vibrerar det svagt när någon lägger sin hand på det, som om det väntar på en fråga.',
|
||||
effect: {},
|
||||
weight: 7,
|
||||
requires: { minDread: 15 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Ställ en fråga',
|
||||
effect: { dread: 14, ritual: 3, hemlighet: -2 },
|
||||
resultText: 'Bordet svarar. Ingen av er pratar om svaret efteråt, men ni ritar alla samma symbol på varsitt papper, oberoende av varandra.',
|
||||
},
|
||||
{
|
||||
text: 'Kasta ut det',
|
||||
effect: { kaos: 10, andakt: 8 },
|
||||
resultText: 'Ni bär ut bordet till återvinningen. Det står kvar på återvinningsgården i tre dagar innan någon vågar ta det. Ni vill inte veta vem.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── High dread / late game ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'late_torso_lean',
|
||||
text: 'Turning Torso lutar nu tre grader mer än vad byggnadskonstruktören godkänt. Ingenjörerna har mätt om det fem gånger. Alla fem mätningarna skiljer sig åt. Alla fem pekar mot samma punkt i Öresund.',
|
||||
effect: { dread: 25, ritual: 4 },
|
||||
weight: 5,
|
||||
requires: { minDread: 90 },
|
||||
},
|
||||
{
|
||||
id: 'late_streetlights',
|
||||
text: 'Gatlyktorna längs hela Amiralsgatan slocknar samtidigt varje natt klockan 03.13, i exakt tolv sekunder. Kommunen hittar inget fel. Ingen i Malmö sover mellan 03.00 och 03.15 längre, men ingen pratar om det.',
|
||||
effect: { dread: 30, hemlighet: -6 },
|
||||
weight: 5,
|
||||
requires: { minDread: 120 },
|
||||
},
|
||||
{
|
||||
id: 'late_choir_beneath',
|
||||
text: 'En kör hörs nu under staden, tydligast vid tunnelbanans arbetsschakt vid Triangeln. Arbetarna har slutat gräva djupare. De säger att marken "svarar för nära" om de fortsätter.',
|
||||
effect: { dread: 35, ritual: 6 },
|
||||
weight: 4,
|
||||
requires: { minDread: 160 },
|
||||
},
|
||||
{
|
||||
id: 'late_sun_wrong',
|
||||
text: 'Solen gick upp i väster i morse. Den korrigerade sig själv inom en minut. SMHI kallar det en "atmosfärisk synvilla." Ingen som såg det tror på den förklaringen, inklusive de som skrev den.',
|
||||
effect: { dread: 40, kaos: 15 },
|
||||
weight: 4,
|
||||
requires: { minDread: 200 },
|
||||
},
|
||||
{
|
||||
id: 'late_children_hum',
|
||||
text: 'Barnen på förskolan vid Bellevuegården har börjat nynna samma melodi, oberoende av varandra, utan att någon lärt dem den. Personalen har slutat fråga varifrån den kommer. De nynnar den nu också.',
|
||||
effect: { dread: 45, hemlighet: -10, troende: 1 },
|
||||
weight: 4,
|
||||
requires: { minDread: 250 },
|
||||
},
|
||||
{
|
||||
id: 'late_sky_seam',
|
||||
text: 'Himlen över Malmö har fått en söm. Den syns bara i skymningen, ett tunt rakt streck rakt över Turning Torso, som om något stort suttit ihop fel. Ingen har fotograferat den framgångsrikt än. Kamerorna vägrar.',
|
||||
effect: { dread: 55, ritual: 8 },
|
||||
weight: 3,
|
||||
requires: { minDread: 320 },
|
||||
},
|
||||
{
|
||||
id: 'late_final_stillness',
|
||||
text: 'Hela Malmö är alldeles tyst i tre sekunder klockan fyra på morgonen. Trafiken stannar. Fåglarna tystnar. Ingen märker det utom ni. Ni vet alla exakt vad det betydde. Ingen säger det högt.',
|
||||
effect: { dread: 60, ritual: 10, hemlighet: -8 },
|
||||
weight: 3,
|
||||
requires: { minDread: 400 },
|
||||
},
|
||||
{
|
||||
id: 'choice_final_congregation',
|
||||
text: 'En troende ni inte känner igen står plötsligt mitt i cirkeln under ett möte. Hon säger att hon "alltid varit här" och att ni bara "inte sett henne förrän nu." Ingen minns att hon klev in.',
|
||||
effect: {},
|
||||
weight: 4,
|
||||
requires: { minDread: 150 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Ge henne en plats i cirkeln',
|
||||
effect: { troende: 1, dread: 20, ritual: 5 },
|
||||
resultText: 'Ni flyttar ihop för att göra plats. Det behövdes ingen extra stol. Det fanns redan en.',
|
||||
},
|
||||
{
|
||||
text: 'Fråga vem hon är',
|
||||
effect: { hemlighet: -5, kaos: 10 },
|
||||
resultText: 'Hon svarar. Ingen av er minns svaret efteråt, men alla nickar som om det var uppenbart.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
{
|
||||
id: 'mollan_market_stall',
|
||||
text: 'En marknadshandlare på Möllevångstorget säger att hon känner igen er, fast ingen av er har handlat hos henne förut. Hon lägger till en extra påse i kassen utan att fråga. Innehållet är inte på svenska.',
|
||||
effect: { andakt: 12, kaos: 6 },
|
||||
weight: 6,
|
||||
requires: { unlockedArea: 'rosengard' },
|
||||
},
|
||||
{
|
||||
id: 'university_manuscript_hit',
|
||||
text: 'En sökning på universitetsbiblioteket ger en enda träff på en fras ur Skriften, i en avhandling om maritim folktro från 1958. Författaren dog samma år. Boken är utlånad. Den har alltid varit utlånad.',
|
||||
effect: { dread: 14, ritual: 3 },
|
||||
weight: 5,
|
||||
requires: { minRitual: 15 },
|
||||
},
|
||||
{
|
||||
id: 'choice_survey_call',
|
||||
text: 'Statistiska centralbyrån ringer och frågar om er upplevda livskvalitet, på en skala ett till tio. Frågorna blir successivt mer specifika. Till slut frågar hen om ni "hör röster som inte är era egna, och om ni lyssnar."',
|
||||
effect: {},
|
||||
weight: 5,
|
||||
requires: { minDread: 25 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Svara ärligt',
|
||||
effect: { hemlighet: -15, dread: 5 },
|
||||
resultText: 'Hen tackar för svaret utan att låta förvånad. Samtalet avslutas normalt. Ni är inte säkra på vem ni just pratade med.',
|
||||
},
|
||||
{
|
||||
text: 'Ljug och lägg på',
|
||||
effect: { hemlighet: 5, kaos: 8 },
|
||||
resultText: 'Ni lägger på mitt i en mening. Telefonen ringer igen tre sekunder senare. Ni svarar inte.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bus_2_driver',
|
||||
text: 'Föraren på linje 2 hälsar er vid namn när ni kliver på, trots att ingen av er sagt det. Bussen kör en rutt som inte finns i tidtabellen och släpper av er exakt utanför källaren, tio minuter tidigare än ni räknat med.',
|
||||
effect: { dread: 9, andakt: 6 },
|
||||
weight: 6,
|
||||
requires: { minDread: 15 },
|
||||
},
|
||||
{
|
||||
id: 'malmo_live_reflection',
|
||||
text: 'Glasfasaden på Malmö Live speglar en himmel med fel antal moln jämfört med den riktiga. Ni räknar två gånger. Talen stämmer inte överens med varandra heller.',
|
||||
effect: { kaos: 9, dread: 6 },
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'choice_old_photograph',
|
||||
text: 'En gammal svartvit fotografi dyker upp i källaren, ingen minns var den kom ifrån. Den föreställer nio personer runt ett bord i exakt samma rum. Kläderna är från 1930-talet. En av personerna ser ut som Broder Lindqvist.',
|
||||
effect: {},
|
||||
weight: 4,
|
||||
requires: { minDread: 70, minRitual: 1 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Visa fotografiet för Lindqvist',
|
||||
effect: { dread: 15, andakt: 10 },
|
||||
resultText: 'Lindqvist tittar länge. Han säger att han känner igen rummet men inte människorna. Han lägger fotografiet i sin ficka istället för att lämna tillbaka det.',
|
||||
},
|
||||
{
|
||||
text: 'Göm fotografiet',
|
||||
effect: { kaos: 12, hemlighet: -5 },
|
||||
resultText: 'Ni lägger det längst ner i en låda. Nästa vecka ligger det på bordet igen, överst.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
|
||||
@@ -449,9 +815,10 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
export const KAOS_EVENTS: GameEvent[] = [
|
||||
{
|
||||
id: 'k_goran_dog',
|
||||
text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantingen. Tvärtom. Bamse leder nu det andra kvartalets böner.',
|
||||
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',
|
||||
@@ -479,7 +846,7 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
},
|
||||
{
|
||||
id: 'k_rival_cult',
|
||||
text: 'En rivaliserande sekt med bas i Hässleholm försöker summera en annan entitet. De summerar fel. De summerar IKEA:s kundtjänst. Det är värre.',
|
||||
text: 'En rivaliserande sekt med bas i Hässleholm försöker mana fram en annan entitet. De manar fel. De manar fram IKEA:s kundtjänst. Det är värre.',
|
||||
effect: { kaos: -45, dread: 50, hemlighet: 10 },
|
||||
weight: 7,
|
||||
},
|
||||
@@ -516,6 +883,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',
|
||||
@@ -529,6 +897,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',
|
||||
@@ -539,7 +908,7 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
},
|
||||
{
|
||||
id: 'k_fager_library',
|
||||
text: 'Stadsbiblioteket på Kung Oscars väg har ett rum i källaren som bara öppnas under kaos. Vi var sist dit. Samlingen är imponerande. Returndatumet är omöjligt.',
|
||||
text: 'Stadsbiblioteket på Kung Oscars väg har ett rum i källaren som bara öppnas under kaos. Vi var sist dit. Samlingen är imponerande. Returdatumet är omöjligt.',
|
||||
effect: { kaos: -55, ritual: 12, dread: 20 },
|
||||
weight: 5,
|
||||
requires: { minDread: 30 },
|
||||
@@ -549,7 +918,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',
|
||||
@@ -567,7 +936,7 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
},
|
||||
{
|
||||
id: 'k_malmofestivalen',
|
||||
text: 'Malmöfestivalen har av misstag bokat oss för en scen vid Gustav Adolfs Torg. Chantingen tolkas som experimentell körmusik. Vi vinner publikens pris. Ingen av juryn minns att de röstade.',
|
||||
text: 'Malmöfestivalen har av misstag bokat oss för en scen vid Gustav Adolfs Torg. Chantandet tolkas som experimentell körmusik. Vi vinner publikens pris. Ingen av juryn minns att de röstade.',
|
||||
effect: { kaos: -35, andakt: 40, troende: 2, hemlighet: -12 },
|
||||
weight: 7,
|
||||
},
|
||||
@@ -606,4 +975,99 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
weight: 5,
|
||||
requires: { minDread: 40 },
|
||||
},
|
||||
{
|
||||
id: 'k_ica_hoarding',
|
||||
text: 'Alla ICA-butiker i Malmö har fått slut på konservburkar med fisk samtidigt. Kunderna köper allt de kan bära utan att kunna förklara varför. En kassörska säger att hyllorna "kändes hungrigare än vanligt."',
|
||||
effect: { kaos: -30, andakt: 15, troende: 1 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'k_pigeon_choir',
|
||||
text: 'Duvorna över hela innerstaden har synkroniserat sitt kuttrande till en enda rytm. Ornitologer vid universitetet kallar det "statistiskt osannolikt." Ingen av dem vågar kalla det något annat.',
|
||||
effect: { kaos: -35, dread: 20 },
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: 'k_bridge_toll_glitch',
|
||||
text: 'Betalstationen vid Öresundsbron har börjat ta betalt i en valuta ingen känner igen. Systemet accepterar den ändå. Bokföringen går ihop perfekt. Ingen vill fråga hur.',
|
||||
effect: { kaos: -45, andakt: 30, dread: 25 },
|
||||
weight: 6,
|
||||
requires: { minDread: 60 },
|
||||
},
|
||||
{
|
||||
id: 'k_missing_hour',
|
||||
text: 'Klockan blev plötsligt två timmar senare än den borde för alla i Malmö samtidigt, utan att någon märkte tidshoppet medan det hände. Ingen saknar den förlorade timmen. Det är det som oroar mest.',
|
||||
effect: { kaos: -70, dread: 65, ritual: 7 },
|
||||
weight: 3,
|
||||
requires: { minDread: 100 },
|
||||
},
|
||||
{
|
||||
id: 'k_second_torso',
|
||||
text: 'En andra Turning Torso syns i horisonten i tio minuter varje kväll, en spegelbild som inte har någon vattenyta att spegla sig i. Turistbyrån har börjat sälja vykort på den. De säljer bra.',
|
||||
effect: { kaos: -75, dread: 70, ritual: 10 },
|
||||
weight: 2,
|
||||
requires: { minDread: 180 },
|
||||
},
|
||||
{
|
||||
id: 'k_everyone_dreams_same',
|
||||
text: 'Hela Malmö drömde samma dröm i natt. Ingen minns innehållet, bara att den var er. Sömnforskare vid Lund har ingen förklaring. De har slutat publicera sina fynd om natten.',
|
||||
effect: { kaos: -65, troende: 2, ritual: 12, hemlighet: -12 },
|
||||
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 },
|
||||
},
|
||||
{
|
||||
id: 'k_mollan_food_truck',
|
||||
text: 'En food truck som ingen beställt eller godkänt har parkerat på Möllevångstorget och serverar en rätt som inte står på skylten till alla som frågar. Kommunen kan inte hitta några tillståndspapper. Truckens ägare finns inte i något register.',
|
||||
effect: { kaos: -40, troende: 2, andakt: 20 },
|
||||
weight: 7,
|
||||
requires: { minTroende: 3 },
|
||||
},
|
||||
{
|
||||
id: 'k_university_thesis_defense',
|
||||
text: 'En doktorandförsvar vid universitetet avbryts när opponenten ställer en fråga ingen i publiken hör men alla ser avhandlingsförfattaren svara på. Betyget blir godkänt med beröm. Ingen av professorerna minns frågan efteråt.',
|
||||
effect: { kaos: -45, ritual: 6, dread: 20 },
|
||||
weight: 5,
|
||||
requires: { minRitual: 10 },
|
||||
},
|
||||
{
|
||||
id: 'k_all_clocks_torso',
|
||||
text: 'Alla digitala klockor inom synhåll från Turning Torso har visat samma sekund i över en timme. Analoga klockor går som vanligt. Ingen teknisk avdelning kan förklara det, och ingen verkar särskilt intresserad av att försöka.',
|
||||
effect: { kaos: -60, dread: 50, ritual: 8 },
|
||||
weight: 4,
|
||||
requires: { unlockedArea: 'torso', minDread: 70 },
|
||||
},
|
||||
{
|
||||
id: 'k_ribersborg_tide',
|
||||
text: 'Tidvattnet vid Ribersborg går ut längre än någonsin uppmätt, i tio minuter, och avslöjar stenformationer på havsbotten som inte finns på någon sjökarta. Sedan kommer vattnet tillbaka som om inget hänt. Fotografierna på nätet försvinner inom en timme.',
|
||||
effect: { kaos: -55, dread: 45, ritual: 6 },
|
||||
weight: 4,
|
||||
requires: { minDread: 90 },
|
||||
},
|
||||
];
|
||||
|
||||
76
src/main.ts
@@ -1,16 +1,56 @@
|
||||
import './style.css';
|
||||
import { createState, tick, buyUpgrade, visitLocation, saveState, loadState, clearSave } from './engine.ts';
|
||||
import { initUI, renderState } from './ui.ts';
|
||||
import { initMap, updateMap } from './map.ts';
|
||||
import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave, applyOfflineProgress, VISITABLE_LOCATION_IDS } from './engine.ts';
|
||||
import { initUI, renderState, setPausedUI, showLocationArt } from './ui.ts';
|
||||
import { initMap, updateMap, LOCATION_INFO } 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;
|
||||
const TARGET_FPS = 30;
|
||||
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')!;
|
||||
|
||||
@@ -22,7 +62,11 @@ function start(): void {
|
||||
visitCooldowns = new Map();
|
||||
document.getElementById('overlay')!.classList.remove('visible');
|
||||
renderState(state);
|
||||
}
|
||||
},
|
||||
(index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); },
|
||||
() => { state = startGame(state); renderState(state); saveState(state); },
|
||||
() => { state = abandonGame(state); renderState(state); saveState(state); },
|
||||
() => { paused = !paused; setPausedUI(paused); }
|
||||
);
|
||||
|
||||
initMap('map', (locationId) => {
|
||||
@@ -30,6 +74,9 @@ function start(): void {
|
||||
state = result.state;
|
||||
visitCooldowns = result.cooldowns;
|
||||
renderState(state);
|
||||
|
||||
const info = LOCATION_INFO[locationId];
|
||||
if (info) showLocationArt(locationId, info.label, info.blurb);
|
||||
});
|
||||
renderState(state);
|
||||
|
||||
@@ -39,13 +86,32 @@ function start(): void {
|
||||
function loop(now: number): void {
|
||||
if (now - lastTime >= FRAME_MS) {
|
||||
lastTime = now;
|
||||
if (state.phase === 'playing') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
82
src/map.ts
@@ -10,15 +10,62 @@ export interface MapMarker {
|
||||
dreadLevel: number; // 0-3
|
||||
}
|
||||
|
||||
const LOCATIONS: Omit<MapMarker, 'dreadLevel'>[] = [
|
||||
{ id: 'rosengard', lat: 55.5894, lng: 13.0414, label: 'Källaren i Rosengård' },
|
||||
{ id: 'varnhem', lat: 55.5982, lng: 13.0058, label: 'Värnhemstorget' },
|
||||
{ id: 'torso', lat: 55.6130, lng: 12.9758, label: 'Turning Torso' },
|
||||
{ id: 'oresund', lat: 55.5789, lng: 12.9200, label: 'Øresundsbron' },
|
||||
{ id: 'ribersborg',lat: 55.6063, lng: 12.9652, label: 'Ribersborgsstranden' },
|
||||
{ id: 'davidshall',lat: 55.5963, lng: 13.0021, label: 'Davidshallsgatan' },
|
||||
{ id: 'stortorget',lat: 55.6047, lng: 13.0038, label: 'Stortorget' },
|
||||
];
|
||||
interface LocationInfo {
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
// A standing description of the place itself, shown in the art modal —
|
||||
// distinct from LOCATION_VISITS' randomized per-visit outcome text in
|
||||
// engine.ts, which changes every time. This doesn't.
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
const LOCATIONS: Record<string, LocationInfo> = {
|
||||
rosengard: {
|
||||
lat: 55.5894, lng: 13.0414, label: 'Källaren i Rosengård',
|
||||
blurb: 'Ett källarförråd som blev något annat. Ingen av er minns exakt när. Hyresvärden vet fortfarande inte om det.',
|
||||
},
|
||||
varnhem: {
|
||||
lat: 55.5982, lng: 13.0058, label: 'Värnhemstorget',
|
||||
blurb: 'Ett vanligt torg med spårvagnshållplats och krita på asfalten som inte suddas, hur mycket det än regnar.',
|
||||
},
|
||||
torso: {
|
||||
lat: 55.6130, lng: 12.9758, label: 'Turning Torso',
|
||||
blurb: 'Calatrava-Santiago ritade byggnaden som en vridande ryggrad. Det visade sig inte vara en metafor.',
|
||||
},
|
||||
oresund: {
|
||||
lat: 55.5789, lng: 12.9200, label: 'Øresundsbron',
|
||||
blurb: 'Bron till Danmark. Vattnet under den är stillare än det borde vara, oftare än det borde vara.',
|
||||
},
|
||||
ribersborg: {
|
||||
lat: 55.6063, lng: 12.9652, label: 'Ribersborgsstranden',
|
||||
blurb: 'En strand med kallbadhus och änder som står stilla i vattenbrynet, vända mot samma håll, oavsett väder.',
|
||||
},
|
||||
davidshall: {
|
||||
lat: 55.5963, lng: 13.0021, label: 'Davidshallsgatan',
|
||||
blurb: 'Ett bostadshus med kakel från 1923 i trapphuset. Kallt att röra vid, oavsett årstid.',
|
||||
},
|
||||
stortorget: {
|
||||
lat: 55.6047, lng: 13.0038, label: 'Stortorget',
|
||||
blurb: 'Stadens gamla torg, statyn i mitten, klockor på fasaderna runtom som sällan visar exakt samma tid.',
|
||||
},
|
||||
mollan: {
|
||||
lat: 55.5904, lng: 13.0203, label: 'Möllevångstorget',
|
||||
blurb: 'Marknad, dofter från femtio kök samtidigt, musik från tre högtalare som aldrig riktigt spelar samma låt.',
|
||||
},
|
||||
universitet: {
|
||||
lat: 55.6083, lng: 12.9974, label: 'Malmö universitet',
|
||||
blurb: 'Bibliotek och föreläsningssalar. Ett rum för sällan efterfrågade samlingar som er kod alltid fungerar till.',
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCATION_INFO: Record<string, { label: string; blurb: string }> =
|
||||
Object.fromEntries(Object.entries(LOCATIONS).map(([id, l]) => [id, { label: l.label, blurb: l.blurb }]));
|
||||
|
||||
// Optional tiny preview shown in each location's tooltip — same
|
||||
// missing-asset-is-fine convention as the character portraits. Path is
|
||||
// tried once per marker; a 404 just means the tooltip falls back to text.
|
||||
const THUMB = (id: string): string => `/img/locations/${id}.png`;
|
||||
|
||||
type LeafletMap = ReturnType<typeof L.map>;
|
||||
type LeafletMarker = ReturnType<typeof L.circleMarker>;
|
||||
@@ -47,16 +94,23 @@ export function initMap(containerId: string, onClick: (id: string) => void): voi
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
for (const loc of LOCATIONS) {
|
||||
for (const [id, loc] of Object.entries(LOCATIONS)) {
|
||||
const m = L.circleMarker([loc.lat, loc.lng], {
|
||||
radius: 6,
|
||||
color: '#1a1a2e',
|
||||
fillColor: '#1a1a2e',
|
||||
fillOpacity: 0.3,
|
||||
weight: 1,
|
||||
}).addTo(map).bindTooltip(loc.label, { className: 'map-tooltip', permanent: false });
|
||||
m.on('click', () => onClick(loc.id));
|
||||
markers.set(loc.id, m);
|
||||
}).addTo(map).bindTooltip(
|
||||
`<div class="map-tooltip-inner">
|
||||
<img class="map-tooltip-thumb" src="${THUMB(id)}" alt=""
|
||||
onerror="this.remove()">
|
||||
<span>${loc.label}</span>
|
||||
</div>`,
|
||||
{ className: 'map-tooltip', permanent: false },
|
||||
);
|
||||
m.on('click', () => onClick(id));
|
||||
markers.set(id, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +125,8 @@ export function updateMap(dread: number, areas: { rosengard: boolean; varnhem: b
|
||||
ribersborg: Math.min(2, Math.floor(dread / 45)),
|
||||
davidshall: Math.min(1, Math.floor(dread / 70)),
|
||||
stortorget: Math.min(2, Math.floor(dread / 55)),
|
||||
mollan: Math.min(2, Math.floor(dread / 48)),
|
||||
universitet: Math.min(2, Math.floor(dread / 65)),
|
||||
};
|
||||
|
||||
for (const [id, marker] of markers) {
|
||||
|
||||
296
src/style.css
@@ -36,6 +36,29 @@ body {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
gap: 0;
|
||||
filter: hue-rotate(calc(var(--dread-intensity, 0) * -35deg)) saturate(calc(1 + var(--dread-intensity, 0) * 0.7));
|
||||
transition: filter 2s ease;
|
||||
}
|
||||
|
||||
#app.kaos-glitch { animation: kaos-glitch-shake 0.4s steps(2, end) 2; }
|
||||
|
||||
@keyframes kaos-glitch-shake {
|
||||
0% { transform: translate(0, 0); }
|
||||
20% { transform: translate(-2px, 1px); filter: hue-rotate(20deg); }
|
||||
40% { transform: translate(2px, -1px); filter: hue-rotate(-15deg); }
|
||||
60% { transform: translate(-1px, 2px); filter: hue-rotate(10deg); }
|
||||
80% { transform: translate(1px, -2px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
#danger-vignette {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 999; /* below modals (1000/1001), above map/log */
|
||||
background: radial-gradient(ellipse at center, transparent 55%, var(--danger) 150%);
|
||||
opacity: var(--hemlighet-danger, 0);
|
||||
transition: opacity 1.5s ease;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
@@ -138,12 +161,26 @@ body {
|
||||
color: var(--text) !important;
|
||||
font-family: inherit !important;
|
||||
font-size: 11px !important;
|
||||
padding: 3px 8px !important;
|
||||
padding: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.map-tooltip::before { display: none !important; }
|
||||
|
||||
.map-tooltip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.map-tooltip-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-tooltip-inner span { padding-right: 2px; }
|
||||
|
||||
/* ── Upgrades ── */
|
||||
.upgrade-btn {
|
||||
display: block;
|
||||
@@ -207,22 +244,85 @@ body {
|
||||
.log-entry.system { color: var(--dim); font-style: italic; }
|
||||
.log-entry.upgrade { color: var(--accent); }
|
||||
|
||||
/* ── Intro screen ── */
|
||||
#intro-screen {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1002; /* above help modal and overlay */
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--bg);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
#intro-screen.visible { display: flex; }
|
||||
|
||||
.intro-scrim {
|
||||
background: linear-gradient(180deg, rgba(6,6,14,0) 0%, rgba(6,6,14,0.75) 35%, rgba(6,6,14,0.97) 100%);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 18vh 20px 8vh;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 8px;
|
||||
color: var(--dread);
|
||||
text-shadow: 0 0 30px rgba(204, 68, 204, 0.5);
|
||||
}
|
||||
|
||||
.intro-sub {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.intro-flavor {
|
||||
max-width: 480px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
margin: 8px 20px 4px;
|
||||
}
|
||||
|
||||
/* ── Overlays ── */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 6, 14, 0.92);
|
||||
background-color: var(--bg);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#overlay.visible { display: flex; }
|
||||
|
||||
.overlay-scrim {
|
||||
background: radial-gradient(ellipse 55% 42% at center, rgba(6,6,14,0.8) 0%, rgba(6,6,14,0.5) 55%, rgba(6,6,14,0.12) 80%, transparent 100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.overlay-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
@@ -269,9 +369,30 @@ body {
|
||||
.phase-tag.mid { color: var(--accent); border-color: var(--accent); }
|
||||
.phase-tag.late { color: var(--dread); border-color: var(--dread); }
|
||||
|
||||
/* ── Help button ── */
|
||||
.help-btn {
|
||||
.pause-tag {
|
||||
margin-left: 8px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--kaos);
|
||||
}
|
||||
|
||||
/* Dim the world a touch while paused, so it reads as "held" rather than "broken" */
|
||||
#app.paused #map-container,
|
||||
#app.paused #panel-left,
|
||||
#app.paused #panel-right {
|
||||
filter: grayscale(0.5) brightness(0.75);
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Help / header buttons ── */
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--dim);
|
||||
@@ -283,6 +404,7 @@ body {
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.help-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
#abandon-btn:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
/* ── Help modal ── */
|
||||
.help-modal {
|
||||
@@ -343,6 +465,18 @@ body {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.event-modal-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.event-modal-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.help-section-title {
|
||||
@@ -374,6 +508,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;
|
||||
}
|
||||
@@ -391,6 +601,78 @@ body {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.archive-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
width: fit-content;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.archive-toggle-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dim);
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
.archive-toggle-btn + .archive-toggle-btn { border-left: 1px solid var(--border); }
|
||||
.archive-toggle-btn.active { background: var(--accent); color: #0d0d1a; }
|
||||
|
||||
.character-card:not(.locked) .character-portrait { cursor: pointer; }
|
||||
.character-card:not(.locked) .character-portrait:hover img { filter: grayscale(0) contrast(1.1) brightness(1.08); }
|
||||
|
||||
.lightbox {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(4, 4, 10, 0.94);
|
||||
z-index: 1100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
}
|
||||
.lightbox.visible { display: flex; }
|
||||
.lightbox img {
|
||||
max-width: min(90vw, 720px);
|
||||
max-height: 78vh;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.lightbox-caption {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.lightbox-blurb {
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
line-height: 1.7;
|
||||
max-width: 56ch;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
.lightbox-blurb:empty { display: none; }
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dim);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lightbox-close:hover { color: var(--text); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#app { grid-template-columns: 1fr; }
|
||||
.panel { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
|
||||
314
src/ui.ts
@@ -1,14 +1,41 @@
|
||||
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): void {
|
||||
export function initUI(
|
||||
app: HTMLElement,
|
||||
onUpgrade: (id: string) => void,
|
||||
onRestart: () => void,
|
||||
onEventChoice: (index: number) => void,
|
||||
onStart: () => void,
|
||||
onAbandon: () => void,
|
||||
onTogglePause: () => void,
|
||||
): void {
|
||||
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></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 & Ä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">
|
||||
@@ -50,6 +77,23 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
|
||||
</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">
|
||||
<div class="archive-toggle" role="group" aria-label="Visningsläge">
|
||||
<button type="button" class="archive-toggle-btn active" data-archive="story">Under spelets gång</button>
|
||||
<button type="button" class="archive-toggle-btn" data-archive="full">Fullständigt arkiv</button>
|
||||
</div>
|
||||
<p class="help-flavor" id="characters-hint" 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>
|
||||
|
||||
@@ -103,12 +147,21 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-right">
|
||||
<div class="panel-title">Aktiva områden</div>
|
||||
<div class="panel-title">Ritualens kärnplatser</div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -116,13 +169,43 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<div id="lightbox" class="lightbox">
|
||||
<button id="lightbox-close" class="lightbox-close" aria-label="Stäng">✕</button>
|
||||
<img id="lightbox-img" alt="">
|
||||
<div id="lightbox-caption" class="lightbox-caption"></div>
|
||||
<p id="lightbox-blurb" class="lightbox-blurb"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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')!;
|
||||
@@ -131,6 +214,46 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
|
||||
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'); });
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('.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<HTMLElement>('[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'); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lightbox.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%;';
|
||||
@@ -146,7 +269,11 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
|
||||
});
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -158,6 +285,11 @@ export function renderState(state: State): void {
|
||||
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 = '';
|
||||
@@ -196,8 +328,41 @@ export function renderState(state: State): void {
|
||||
// Log
|
||||
renderLog(state);
|
||||
|
||||
// Intro screen
|
||||
document.getElementById('intro-screen')!.classList.toggle('visible', state.phase === 'intro');
|
||||
|
||||
// Overlay
|
||||
if (state.phase !== 'playing') showOverlay(state);
|
||||
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');
|
||||
}
|
||||
|
||||
let lastUpgradeKey = '';
|
||||
@@ -237,17 +402,110 @@ function renderUpgrades(state: State): void {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
let lastLogLength = 0;
|
||||
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.log.length === lastLogLength) return;
|
||||
lastLogLength = state.log.length;
|
||||
// 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('');
|
||||
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 ? `<div class="log-entry ${newest.kind}" id="log-newest"></div>` : '')
|
||||
+ rest.map(e => `<div class="log-entry ${e.kind}">${e.text}</div>`).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 `
|
||||
<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 {
|
||||
@@ -256,16 +514,50 @@ function showOverlay(state: State): void {
|
||||
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;
|
||||
|
||||
@@ -78,8 +78,8 @@ export const UPGRADES: Upgrade[] = [
|
||||
id: 'torso_beacon',
|
||||
name: 'Altaret vid Turning Torso',
|
||||
desc: 'Calatrava-Santiago ritade byggnaden som en vridande ryggrad. Det är korrekt. Det är inte en metafor. Byggnaden har alltid lyssnat. Nu ber vi den svara. Den gör det. Svaret är ett ljud under hörselgränsen som vi känner i tänderna.',
|
||||
cost: () => ({ andakt: 400, dread: 100 }),
|
||||
effect: s => { s.areas.torso = true; s.dreadRate += 3; s.ritualRate += 0.5; },
|
||||
cost: () => ({ andakt: 500, dread: 100 }),
|
||||
effect: s => { s.areas.torso = true; s.dreadRate += 3; s.ritualRate += 0.15; },
|
||||
available: s => s.upgrades.has('varnhem_circle') && s.dread >= 100 && !s.upgrades.has('torso_beacon'),
|
||||
once: true,
|
||||
},
|
||||
@@ -87,22 +87,49 @@ export const UPGRADES: Upgrade[] = [
|
||||
id: 'grand_ritual_prep',
|
||||
name: 'Förberedelser för Den Stora Ritualen',
|
||||
desc: 'Allt har lett hit. Turning Torso pekar. Värnhemstorget svarar. Öresund drar sig tillbaka som om det vet vad som kommer. Skriften är nu fullständig. Den sista sidan skrevs av sig själv i natt. Det är ett recept.',
|
||||
cost: () => ({ andakt: 600, dread: 200 }),
|
||||
effect: s => { s.ritualRate += 2; s.kaosRate += 0.5; },
|
||||
cost: () => ({ andakt: 900, dread: 250 }),
|
||||
effect: s => { s.ritualRate += 0.35; s.kaosRate += 0.5; },
|
||||
available: s => s.upgrades.has('torso_beacon') && s.ritual >= 50 && !s.upgrades.has('grand_ritual_prep'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'sunken_choir',
|
||||
name: 'Den sjunkna kören',
|
||||
desc: 'Rösterna under Öresund har börjat svara i takt med era. Ni behöver bara lyssna tillbaka. Det underlättar. Det är också svårt att sluta göra.',
|
||||
cost: () => ({ andakt: 1400, dread: 300 }),
|
||||
effect: s => { s.ritualRate += 0.25; s.andaktRate += 1; },
|
||||
available: s => s.upgrades.has('grand_ritual_prep') && s.dread >= 300 && !s.upgrades.has('sunken_choir'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'nine_chairs_full',
|
||||
name: 'Alla nio stolarna fyllda',
|
||||
desc: 'Källarens nio stolar har äntligen alla sina rätta innehavare. Den varma stolen är också upptagen nu. Ingen pratar om vem som sitter i den.',
|
||||
cost: () => ({ andakt: 2200, dread: 350 }),
|
||||
effect: s => { s.ritualRate += 0.25; s.kaosRate += 0.3; },
|
||||
available: s => s.upgrades.has('sunken_choir') && s.troende >= 9 && !s.upgrades.has('nine_chairs_full'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'deepen_ritual',
|
||||
name: 'Fördjupa ritualen',
|
||||
desc: 'Ännu ett lager av mening under det förra. Andakt är inget värt om det bara samlas i en burk. Varje fördjupning kostar mer än den förra — Den Sovande blir kräsnare ju närmre ni kommer.',
|
||||
cost: s => ({ andakt: Math.round(300 * Math.pow(1.6, ritualDeepenings(s))), dread: 50 }),
|
||||
effect: s => { s.ritualRate += 0.3; },
|
||||
cost: s => ({ andakt: Math.round(350 * Math.pow(1.55, ritualDeepenings(s))), dread: 60 }),
|
||||
effect: s => { s.ritualRate += 0.15; },
|
||||
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 {
|
||||
return Math.round((s.ritualRate - 2.5) / 0.3);
|
||||
return Math.round((s.ritualRate - 0.5) / 0.15);
|
||||
}
|
||||
|
||||