diff --git a/src/engine.ts b/src/engine.ts index 148c4f6..6810f48 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -31,6 +31,7 @@ export interface State { 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 } export interface LogEntry { @@ -75,6 +76,7 @@ export function createState(): State { nextEventTick: EVENT_INTERVAL_TICKS, nextKaosTick: EVENT_INTERVAL_TICKS * 3, pendingEvent: null, + logSeq: 0, }; } @@ -84,11 +86,10 @@ export function startGame(state: State): State { } export function abandonGame(state: State): State { - return { - ...state, - phase: 'abandoned', - log: [{ tick: state.tick, text: 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.', kind: 'system' }, ...state.log], - }; + 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 { @@ -114,6 +115,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)); @@ -159,15 +166,14 @@ export function tick(state: State): State { if (ev) { s.usedEventIds.add(ev.id); if (ev.choices?.length) { - s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' }); + pushLog(s, { 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' }); + 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 @@ -176,26 +182,25 @@ export function tick(state: State): State { if (ev) { s.usedEventIds.add(ev.id); if (ev.choices?.length) { - s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' }); + pushLog(s, { 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' }); + pushLog(s, { 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); - 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; @@ -209,17 +214,16 @@ export function resolveEventChoice(state: State, choiceIndex: number): 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); - 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); + 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.'; - s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' }); + pushLog(s, { 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' }); + 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; @@ -323,8 +327,7 @@ export function visitLocation(state: State, id: string, cooldowns: Map 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); @@ -355,6 +358,7 @@ export function loadState(): State | null { upgrades: new Set(d.upgrades ?? []), usedEventIds: new Set(d.usedEventIds ?? []), flags: new Set(d.flags ?? []), + logSeq: d.logSeq ?? 0, } as State; } catch (_) { return null; @@ -380,7 +384,6 @@ export function buyUpgrade(state: State, id: string): State { 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; } diff --git a/src/events.ts b/src/events.ts index 5c7af69..5136c05 100644 --- a/src/events.ts +++ b/src/events.ts @@ -628,7 +628,7 @@ 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, }, @@ -746,7 +746,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, }, diff --git a/src/main.ts b/src/main.ts index 08c1622..6b0c254 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,11 @@ import './style.css'; import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave } from './engine.ts'; -import { initUI, renderState } from './ui.ts'; +import { initUI, renderState, setPausedUI } from './ui.ts'; import { initMap, updateMap } from './map.ts'; let state = loadState() ?? createState(); let visitCooldowns = new Map(); +let paused = false; let lastTime = 0; const TARGET_FPS = 30; const FRAME_MS = 1000 / TARGET_FPS; @@ -25,7 +26,8 @@ function start(): void { }, (index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); }, () => { state = startGame(state); renderState(state); saveState(state); }, - () => { state = abandonGame(state); renderState(state); saveState(state); } + () => { state = abandonGame(state); renderState(state); saveState(state); }, + () => { paused = !paused; setPausedUI(paused); } ); initMap('map', (locationId) => { @@ -42,7 +44,7 @@ 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); renderState(state); updateMap(state.dread, state.areas); diff --git a/src/style.css b/src/style.css index f2885b4..40934b3 100644 --- a/src/style.css +++ b/src/style.css @@ -355,6 +355,22 @@ body { .phase-tag.mid { color: var(--accent); border-color: var(--accent); } .phase-tag.late { color: var(--dread); border-color: var(--dread); } +.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; diff --git a/src/ui.ts b/src/ui.ts index 1d4a448..332de2a 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -9,12 +9,14 @@ export function initUI( onEventChoice: (index: number) => void, onStart: () => void, onAbandon: () => void, + onTogglePause: () => void, ): void { app.innerHTML = `
-
Aktiva områden
+
Ritualens kärnplatser
○ Rosengård
○ Värnhemstorget
○ Turning Torso
+
+
Övriga platser
+
+
Øresundsbron
+
Ribersborgsstranden
+
Davidshallsgatan
+
Stortorget
+
+
Utredarna
Inaktiva
@@ -163,6 +174,7 @@ export function initUI( 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; @@ -320,12 +332,12 @@ function renderUpgrades(state: State): void { }).join(''); } -let lastLogLength = 0; +let lastLogSeq = -1; function renderLog(state: State): void { const log = document.getElementById('log')!; - if (state.log.length === lastLogLength) return; - lastLogLength = state.log.length; + if (state.logSeq === lastLogSeq) return; + lastLogSeq = state.logSeq; // state.log is newest-first (unshift); render as-is, scroll to top log.innerHTML = state.log.map(e => `
${e.text}
` @@ -363,6 +375,13 @@ function showOverlay(state: State): void { } } +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;