Add intro screen, win/loss art, and a way to abandon the ritual

- Adds a full-screen intro/title screen (new 'intro' game phase) with
  a moody Malmö/Turning Torso background and a "Börja ritualen"
  button; the game no longer starts ticking until the player begins.
- Win and loss overlays now show matching generated artwork behind
  the existing text, instead of a flat scrim.
- New "Avbryt" header button lets the player abandon a run mid-game
  (with a confirm prompt) via a new 'abandoned' phase, reusing the
  existing overlay/restart flow.
- Source images converted from PNG (~2.1MB each) to WebP (~120KB
  each) and placed under public/img/ so Vite serves them as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-13 09:54:46 +02:00
parent 10c7999701
commit 4d47409e03
8 changed files with 130 additions and 10 deletions

BIN
public/img/fail.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
public/img/intro.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
public/img/win.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -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

View File

@@ -24,7 +24,7 @@ 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>;
@@ -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;

View File

@@ -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) => {

View File

@@ -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 {

View File

@@ -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 = `
<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>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
<div class="header-actions">
<button id="abandon-btn" class="help-btn" title="Överge kulten">Avbryt</button>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ä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">
@@ -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.';