Compare commits
2 Commits
10c7999701
...
d039dfb019
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d039dfb019 | ||
|
|
4d47409e03 |
BIN
public/img/fail.webp
Normal file
BIN
public/img/fail.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
BIN
public/img/intro.webp
Normal file
BIN
public/img/intro.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
public/img/win.webp
Normal file
BIN
public/img/win.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
@@ -1,4 +1,4 @@
|
||||
export const VERSION = '0.3.0';
|
||||
export const VERSION = '0.4.0';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -7,6 +7,19 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
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, 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -172,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,
|
||||
},
|
||||
@@ -560,7 +560,7 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
},
|
||||
{
|
||||
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 riktig — men ni ser henne titta på dörren mot Värnhemstorget på ett nytt sätt.',
|
||||
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 },
|
||||
@@ -658,7 +658,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,
|
||||
},
|
||||
@@ -718,7 +718,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 },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
48
src/ui.ts
48
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 = `
|
||||
<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 & Ä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 & Ä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.';
|
||||
|
||||
Reference in New Issue
Block a user