diff --git a/src/engine.ts b/src/engine.ts index b292c7f..90ccac3 100644 --- a/src/engine.ts +++ b/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 { @@ -24,6 +29,7 @@ export interface State { usedEventIds: Set; nextEventTick: number; nextKaosTick: number; + pendingEvent: PendingEvent | null; } export interface LogEntry { @@ -66,6 +72,7 @@ export function createState(): State { usedEventIds: new Set(), nextEventTick: EVENT_INTERVAL_TICKS, nextKaosTick: EVENT_INTERVAL_TICKS * 3, + pendingEvent: null, }; } @@ -107,6 +114,12 @@ export function tick(state: State): State { const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) }; 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,24 +137,34 @@ 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) { + s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' }); + s.pendingEvent = { event: ev, kind: 'event' }; + } else { + s.log.unshift({ 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' }); - applyEffect(s, ev.effect); s.usedEventIds.add(ev.id); + if (ev.choices?.length) { + s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' }); + s.pendingEvent = { event: ev, kind: 'kaos' }; + } else { + s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' }); + applyEffect(s, ev.effect); + } } s.kaos = Math.max(0, s.kaos - 80); s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120); @@ -161,6 +184,29 @@ export function tick(state: State): State { 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) }; + applyEffect(s, choice.effect); + s.log.unshift({ tick: s.tick, text: `↳ ${choice.resultText}`, kind: s.pendingEvent!.kind }); + if (s.log.length > 40) s.log = s.log.slice(0, 40); + 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.'; + s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' }); + } else 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' }); + } + + return s; +} + interface LocationVisit { text: string; effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>; diff --git a/src/events.ts b/src/events.ts index 60afb3b..3044cb1 100644 --- a/src/events.ts +++ b/src/events.ts @@ -7,12 +7,19 @@ export type EventEffect = Partial<{ ritual: number; }>; +export interface EventChoice { + text: string; + effect: EventEffect; + resultText: string; +} + export interface GameEvent { id: string; text: string; effect: EventEffect; weight: number; requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>; + choices?: EventChoice[]; } export type Area = 'rosengard' | 'varnhem' | 'torso'; @@ -442,6 +449,81 @@ 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.', + }, + { + text: 'Be honom tiga', + effect: { kaos: 6 }, + resultText: 'Ni tystar honom. Han ler ändå, som om han redan sagt det som behövdes.', + }, + ], + }, + { + 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: '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.', + }, + ], + }, ]; // ── Kaos events (Henderson-tier) ───────────────────────────────────────────── diff --git a/src/main.ts b/src/main.ts index 1088ea8..da4d7a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import './style.css'; -import { createState, tick, buyUpgrade, visitLocation, saveState, loadState, clearSave } from './engine.ts'; +import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, saveState, loadState, clearSave } from './engine.ts'; import { initUI, renderState } from './ui.ts'; import { initMap, updateMap } from './map.ts'; @@ -22,7 +22,8 @@ function start(): void { visitCooldowns = new Map(); document.getElementById('overlay')!.classList.remove('visible'); renderState(state); - } + }, + (index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); } ); initMap('map', (locationId) => { diff --git a/src/style.css b/src/style.css index c3fda44..c87a6c3 100644 --- a/src/style.css +++ b/src/style.css @@ -343,6 +343,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 { diff --git a/src/ui.ts b/src/ui.ts index 656ecaa..9ca2171 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -2,7 +2,7 @@ import type { State } from './engine.ts'; import { UPGRADES } from './upgrades.ts'; import { VERSION, CHANGELOG } from './data/version.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): void { app.innerHTML = ` + +
+
+
+
+
+
+
+
`; document.getElementById('overlay-btn')!.addEventListener('click', onRestart); + document.getElementById('event-modal-choices')!.addEventListener('click', e => { + const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null; + if (btn?.dataset['choice'] !== undefined) onEventChoice(Number(btn.dataset['choice'])); + }); + const helpModal = document.getElementById('help-modal')!; const helpInner = document.getElementById('help-modal-inner')!; document.getElementById('help-btn')!.addEventListener('click', () => helpModal.classList.toggle('visible')); @@ -198,6 +212,31 @@ export function renderState(state: State): void { // Overlay if (state.phase !== 'playing') showOverlay(state); + + // 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) => + `` + ).join(''); + modal.classList.add('visible'); } let lastUpgradeKey = '';