diff --git a/public/img/fail.webp b/public/img/fail.webp new file mode 100644 index 0000000..8c2d9f7 Binary files /dev/null and b/public/img/fail.webp differ diff --git a/public/img/intro.webp b/public/img/intro.webp new file mode 100644 index 0000000..dc45def Binary files /dev/null and b/public/img/intro.webp differ diff --git a/public/img/win.webp b/public/img/win.webp new file mode 100644 index 0000000..8ae3568 Binary files /dev/null and b/public/img/win.webp differ diff --git a/src/engine.test.ts b/src/engine.test.ts index 5881c89..147eeec 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -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, 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 diff --git a/src/engine.ts b/src/engine.ts index f671ac0..148c4f6 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -24,7 +24,7 @@ export interface State { areas: { rosengard: boolean; varnhem: boolean; torso: boolean }; upgrades: Set; log: LogEntry[]; - phase: 'playing' | 'won' | 'lost'; + phase: 'intro' | 'playing' | 'won' | 'lost' | 'abandoned'; lostReason?: string; usedEventIds: Set; flags: Set; @@ -69,7 +69,7 @@ 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, @@ -78,6 +78,19 @@ export function createState(): State { }; } +export function startGame(state: State): State { + if (state.phase !== 'intro') return state; + return { ...state, phase: 'playing' }; +} + +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], + }; +} + function pickEvent(pool: GameEvent[], state: State): GameEvent | null { const available = pool.filter(e => { if (state.usedEventIds.has(e.id)) return false; diff --git a/src/main.ts b/src/main.ts index da4d7a5..08c1622 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import './style.css'; -import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, saveState, loadState, clearSave } from './engine.ts'; +import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave } from './engine.ts'; import { initUI, renderState } from './ui.ts'; import { initMap, updateMap } from './map.ts'; @@ -23,7 +23,9 @@ function start(): void { document.getElementById('overlay')!.classList.remove('visible'); renderState(state); }, - (index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); } + (index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); }, + () => { state = startGame(state); renderState(state); saveState(state); }, + () => { state = abandonGame(state); renderState(state); saveState(state); } ); initMap('map', (locationId) => { diff --git a/src/style.css b/src/style.css index e4a7cfb..a09e975 100644 --- a/src/style.css +++ b/src/style.css @@ -230,12 +230,65 @@ 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: center; +} + +#intro-screen.visible { display: flex; } + +.intro-scrim { + background: linear-gradient(180deg, rgba(6,6,14,0.55) 0%, rgba(6,6,14,0.88) 70%, rgba(6,6,14,0.96) 100%); + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding-bottom: 10vh; + 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-size: cover; + background-position: center; z-index: 1000; align-items: center; justify-content: center; @@ -292,9 +345,14 @@ 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 { +/* ── Help / header buttons ── */ +.header-actions { margin-left: auto; + display: flex; + gap: 8px; +} + +.help-btn { background: none; border: 1px solid var(--border); color: var(--dim); @@ -306,6 +364,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 { diff --git a/src/ui.ts b/src/ui.ts index 21dc96f..8ba461a 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -2,12 +2,35 @@ 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, onEventChoice: (index: number) => void): void { +export function initUI( + app: HTMLElement, + onUpgrade: (id: string) => void, + onRestart: () => void, + onEventChoice: (index: number) => void, + onStart: () => void, + onAbandon: () => void, +): void { app.innerHTML = ` + +
+
+
DJUPET
+
Malmö, Sverige
+

+ Nio stolar i en fuktig källare i Rosengård. En av dem är alltid lite för varm.
+ Ni har hört sången i väggarna. Den Sovande har inte hört er ännu.
+ Det är dags att ändra på det. +

+ +
@@ -134,6 +157,10 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest `; 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('event-modal-choices')!.addEventListener('click', e => { const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null; @@ -217,8 +244,16 @@ 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); @@ -309,10 +344,17 @@ function showOverlay(state: State): void { const title = document.getElementById('overlay-title')!; const body = document.getElementById('overlay-body')!; if (state.phase === 'won') { + ov.style.backgroundImage = "linear-gradient(rgba(6,6,14,0.72), rgba(6,6,14,0.85)), 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 = "linear-gradient(rgba(6,6,14,0.72), rgba(6,6,14,0.85)), 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 = "linear-gradient(rgba(6,6,14,0.72), rgba(6,6,14,0.85)), url('/img/fail.webp')"; title.textContent = 'AVSLÖJAD'; title.className = 'overlay-title lost'; body.textContent = state.lostReason ?? 'Utredarna kom.';