Compare commits

...

4 Commits

Author SHA1 Message Date
Fredrik Johansson
10c7999701 Merge feature/interactive-events: choice events, NPC chains, visual reactivity, location variety
All checks were successful
Docker / build-and-push (push) Successful in 42s
2026-07-13 00:02:58 +02:00
Fredrik Johansson
6ed9d2dac3 Add ambient visual reactivity and location visit variety
- #app's filter (hue-rotate/saturate) now tracks dread continuously,
  and a fixed vignette bleeds red as hemlighet drops below 40 — no
  new assets, just CSS driven by existing state.
- A kaos log entry now triggers a brief screen-glitch shake.
- Location visits pick from 3 flavor-text variants per site instead
  of a single fixed line, so repeat visits don't repeat verbatim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 00:01:51 +02:00
Fredrik Johansson
80fdebf102 Add flag-gated NPC event chains for Broder Lindqvist and Syster Hassan
Events can now depend on a previously-set flag and a completed event
id, letting a choice made early in the game (e.g. whether Lindqvist's
whisper was heeded) shape which follow-up events surface later. Adds
two 3-step chains as a first example of persistent NPC arcs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:53:43 +02:00
Fredrik Johansson
b9561b05a5 Add choice-based events with a resolution modal
Standard/kaos events can now present 2 options instead of a fixed
effect, pausing new event triggers until the player answers. Adds
4 choice events as a first batch; resources keep accruing while a
choice is pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:17:48 +02:00
5 changed files with 371 additions and 22 deletions

View File

@@ -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 {
@@ -22,8 +27,10 @@ export interface State {
phase: 'playing' | 'won' | 'lost';
lostReason?: string;
usedEventIds: Set<string>;
flags: Set<string>;
nextEventTick: number;
nextKaosTick: number;
pendingEvent: PendingEvent | null;
}
export interface LogEntry {
@@ -64,8 +71,10 @@ export function createState(): State {
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',
usedEventIds: new Set(),
flags: new Set(),
nextEventTick: EVENT_INTERVAL_TICKS,
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
pendingEvent: null,
};
}
@@ -78,6 +87,8 @@ function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
if (r.minDread && state.dread < r.minDread) return false;
if (r.minRitual && state.ritual < r.minRitual) return false;
if (r.unlockedArea && !state.areas[r.unlockedArea]) return false;
if (r.flag && !state.flags.has(r.flag)) return false;
if (r.completedEventId && !state.usedEventIds.has(r.completedEventId)) return false;
return true;
});
if (!available.length) return null;
@@ -104,9 +115,15 @@ const DT = 1 / 30; // 30fps
export function tick(state: State): State {
if (state.phase !== 'playing') return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
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 +141,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,8 +188,32 @@ 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), 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);
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;
texts: string[];
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
requiresArea?: keyof State['areas'];
lockedText: string;
@@ -170,40 +221,68 @@ interface LocationVisit {
const LOCATION_VISITS: Record<string, LocationVisit> = {
rosengard: {
text: 'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
texts: [
'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
'Någon har städat källaren medan ingen var där. Ingen av er erkänner sig skyldig. +andakt',
'Den varma stolen är varm igen. Ingen har suttit i den. +andakt',
],
effect: { andakt: 15 },
requiresArea: 'rosengard',
lockedText: 'En fuktig källarlokal i Rosengård. Du kan se ljuset under dörren. Ni är inte redo än.',
},
varnhem: {
text: 'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
texts: [
'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
'Kritan på asfalten har inte suddats trots regnet i natt. +dread +ritual',
'Torget är tomt förutom en duva som går i exakt cirkelns mönster. +dread +ritual',
],
effect: { dread: 8, ritual: 2 },
requiresArea: 'varnhem',
lockedText: 'Värnhemstorget ser normalt ut. Det är ett dåligt tecken.',
},
torso: {
text: 'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
texts: [
'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
'Ett fönster högt uppe är tänt som det aldrig varit förut. Ingen bor där. Ingen har någonsin bott där. +dread +ritual',
'Torso vrider sig en grad till i vinden som inte finns. +dread +ritual',
],
effect: { dread: 15, ritual: 5 },
requiresArea: 'torso',
lockedText: 'Turning Torso vrider sig i vinden. Inte i vinden. I något annat.',
},
oresund: {
text: 'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
texts: [
'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
'En färja passerar utan ljud. Ingen ombord vinkar. Alla ombord tittar rakt på dig. +dread',
'Bron surrar på en frekvens du känner i tänderna snarare än öronen. +dread',
],
effect: { dread: 5 },
lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.',
},
ribersborg: {
text: 'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
texts: [
'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
'Alla änderna vänder sig samtidigt mot Turning Torso när du kommer. +andakt',
'Sanden är kall trots solen. Ingen annan verkar märka det. +andakt',
],
effect: { andakt: 8 },
lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.',
},
davidshall: {
text: 'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
texts: [
'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
'Kaklet från 1923 är kallt att röra vid, oavsett årstid. +andakt',
'Grannen genom väggen sjunger något som nästan, men inte riktigt, är en psalm. +andakt',
],
effect: { andakt: 10 },
lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.',
},
stortorget: {
text: 'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
texts: [
'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
'Statyn på torget har flyttat en centimeter sedan i förra veckan. Du är den enda som mätt. +kaos',
'Alla klockor på torget visar olika tid. Ingen av dem är fel. +kaos',
],
effect: { kaos: 8 },
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
},
@@ -218,10 +297,10 @@ export function visitLocation(state: State, id: string, cooldowns: Map<string, n
const now = state.tick;
if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns };
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
const text = areaLocked ? visit.lockedText : visit.text;
const text = areaLocked ? visit.lockedText : visit.texts[Math.floor(Math.random() * visit.texts.length)];
if (!areaLocked) {
if (visit.effect.andakt) s.andakt += visit.effect.andakt;
@@ -246,6 +325,7 @@ export function saveState(state: State): void {
...state,
upgrades: [...state.upgrades],
usedEventIds: [...state.usedEventIds],
flags: [...state.flags],
log: state.log.slice(0, 20),
};
localStorage.setItem(SAVE_KEY, JSON.stringify(serial));
@@ -261,6 +341,7 @@ export function loadState(): State | null {
...d,
upgrades: new Set<string>(d.upgrades ?? []),
usedEventIds: new Set<string>(d.usedEventIds ?? []),
flags: new Set<string>(d.flags ?? []),
} as State;
} catch (_) {
return null;
@@ -280,7 +361,7 @@ export function buyUpgrade(state: State, id: string): State {
if (cost.dread && state.dread < cost.dread) return state;
if (cost.inflytande && state.inflytande < cost.inflytande) return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
if (cost.andakt) s.andakt -= cost.andakt;
if (cost.dread) s.dread -= cost.dread;
if (cost.inflytande) s.inflytande -= cost.inflytande;

View File

@@ -7,12 +7,27 @@ export type EventEffect = Partial<{
ritual: number;
}>;
export interface EventChoice {
text: string;
effect: EventEffect;
resultText: string;
flag?: string;
}
export interface GameEvent {
id: string;
text: string;
effect: EventEffect;
weight: number;
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>;
requires?: Partial<{
minTroende: number;
minDread: number;
minRitual: number;
unlockedArea: Area;
flag: string;
completedEventId: string;
}>;
choices?: EventChoice[];
}
export type Area = 'rosengard' | 'varnhem' | 'torso';
@@ -442,6 +457,170 @@ 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.',
flag: 'lindqvist_listened',
},
{
text: 'Be honom tiga',
effect: { kaos: 6 },
resultText: 'Ni tystar honom. Han ler ändå, som om han redan sagt det som behövdes.',
flag: 'lindqvist_silenced',
},
],
},
{
id: 'lindqvist_chain_2a',
text: 'Broder Lindqvist har inte sovit på fyra dagar. Han säger att han inte längre behöver det. Han ritar samma karta om och om igen, alltid samma vinkel mot Öresund.',
effect: { dread: 10, andakt: 5 },
weight: 9,
requires: { flag: 'lindqvist_listened', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
},
{
id: 'lindqvist_chain_2b',
text: 'Broder Lindqvist har blivit tystlåten sedan ni bad honom tiga. Han deltar fortfarande, men han skriver nu ner sina drömmar istället för att dela dem. Ingen har läst anteckningsboken. Ingen vågar fråga.',
effect: { hemlighet: -6, kaos: 4 },
weight: 9,
requires: { flag: 'lindqvist_silenced', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
},
{
id: 'lindqvist_chain_3',
text: 'Lindqvist ber att få gå ner till Öresund ensam, på natten, "för att svara ordentligt denna gång." Han har redan sina kläder på sig. Han väntar bara på ert tillstånd.',
effect: {},
weight: 8,
requires: { minDread: 45, completedEventId: 'choice_lindqvist_dream' },
choices: [
{
text: 'Låt honom gå',
effect: { dread: 20, ritual: 4, hemlighet: -8 },
resultText: 'Han går ner mot vattnet och försvinner in i dimman. Han kommer tillbaka i gryningen, torr, leende, och pratar inte längre om drömmar. Han pratar inte om något alls.',
},
{
text: 'Håll fast honom',
effect: { kaos: 15, andakt: 10 },
resultText: 'Ni håller kvar honom till gryningen. Han skriker att ni har gjort ett misstag. Han slutar skrika när solen går upp. Han säger aldrig varför.',
},
],
},
{
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: 'hassan_ambition',
text: 'Syster Hassan har börjat föreslå ändringar i ritualens ordning. Hon säger att hon sett en bättre väg i en dröm. Hon säger det med en säkerhet ni inte kommer ihåg att hon haft förut.',
effect: {},
weight: 8,
requires: { minDread: 15 },
choices: [
{
text: 'Ge henne ansvar',
effect: { andakt: 12, dread: 6 },
resultText: 'Ni låter henne leda nästa moment. Cirkeln följer henne lättare än den någonsin följt er. Det känns bra. Det känns också som en förlust ni inte kan namnge.',
flag: 'hassan_empowered',
},
{
text: 'Behåll ordningen',
effect: { kaos: 5 },
resultText: 'Ni tackar för förslaget och fortsätter som förut. Hassan nickar och säger inget mer. Hon skriver fortfarande i sin anteckningsbok efteråt.',
flag: 'hassan_sidelined',
},
],
},
{
id: 'hassan_chain_2a',
text: 'Syster Hassan leder nu halva mötena. Hon har egna ord för Den Sovande, ord ingen av er lärt henne. Troende lyssnar på henne som de en gång lyssnade på er.',
effect: { andakt: 15, kaos: 5 },
weight: 8,
requires: { flag: 'hassan_empowered', completedEventId: 'hassan_ambition', minDread: 35 },
},
{
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.',
effect: { hemlighet: -5, dread: 8 },
weight: 8,
requires: { flag: 'hassan_sidelined', completedEventId: 'hassan_ambition', minDread: 35 },
},
{
id: 'hassan_chain_3',
text: 'Syster Hassan ber om att få genomföra en egen ritual, separat från er, på Turning Torso. Hon säger att Den Sovande bett om henne specifikt. Hon säger det utan att blinka.',
effect: {},
weight: 7,
requires: { minDread: 55, completedEventId: 'hassan_ambition', unlockedArea: 'torso' },
choices: [
{
text: 'Låt henne göra det',
effect: { dread: 22, ritual: 6, troende: -1 },
resultText: 'Hon går upp ensam i mörkret. Ni ser ljus i fönstren på våningar som inte har fönster. Hon kommer tillbaka annorlunda — mer er, mindre henne, i en riktning ingen kan sätta ord på.',
},
{
text: 'Neka henne',
effect: { kaos: 18, hemlighet: -4 },
resultText: 'Ni säger nej. Hon accepterar det med ett leende som inte når hennes ögon och går tillbaka till sin plats i cirkeln. Ni undrar länge efteråt om ni gjorde rätt val, eller bara det säkra.',
},
],
},
{
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) ─────────────────────────────────────────────

View File

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

View File

@@ -36,6 +36,29 @@ body {
max-width: 1200px;
margin: 0 auto;
gap: 0;
filter: hue-rotate(calc(var(--dread-intensity, 0) * -35deg)) saturate(calc(1 + var(--dread-intensity, 0) * 0.7));
transition: filter 2s ease;
}
#app.kaos-glitch { animation: kaos-glitch-shake 0.4s steps(2, end) 2; }
@keyframes kaos-glitch-shake {
0% { transform: translate(0, 0); }
20% { transform: translate(-2px, 1px); filter: hue-rotate(20deg); }
40% { transform: translate(2px, -1px); filter: hue-rotate(-15deg); }
60% { transform: translate(-1px, 2px); filter: hue-rotate(10deg); }
80% { transform: translate(1px, -2px); }
100% { transform: translate(0, 0); }
}
#danger-vignette {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999; /* below modals (1000/1001), above map/log */
background: radial-gradient(ellipse at center, transparent 55%, var(--danger) 150%);
opacity: var(--hemlighet-danger, 0);
transition: opacity 1.5s ease;
}
/* ── Header ── */
@@ -343,6 +366,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 {

View File

@@ -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 = `
<div id="header">
<div class="brand">DJUPET</div>
@@ -120,10 +120,26 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
<div class="overlay-body" id="overlay-body"></div>
<button class="overlay-btn" id="overlay-btn">Börja om</button>
</div>
<div id="danger-vignette"></div>
<div id="event-modal" class="help-modal">
<div class="help-modal-inner" id="event-modal-inner">
<div class="help-modal-body">
<div class="event-modal-text" id="event-modal-text"></div>
<div class="event-modal-choices" id="event-modal-choices"></div>
</div>
</div>
</div>
`;
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'));
@@ -158,6 +174,11 @@ export function renderState(state: State): void {
setBar('bar-hemlighet', state.hemlighet);
setBar('bar-kaos', state.kaos);
// Ambient visual reactivity — the deeper the dread, the more the world tilts; low hemlighet bleeds red at the edges.
const root = document.documentElement.style;
root.setProperty('--dread-intensity', Math.min(1, state.dread / 150).toFixed(3));
root.setProperty('--hemlighet-danger', (state.hemlighet < 40 ? (1 - state.hemlighet / 40) * 0.35 : 0).toFixed(3));
const ritualSection = document.getElementById('ritual-section')!;
if (state.ritualUnlocked) {
ritualSection.style.display = '';
@@ -198,6 +219,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) =>
`<button class="upgrade-btn" data-choice="${i}"><span class="up-name">${c.text}</span></button>`
).join('');
modal.classList.add('visible');
}
let lastUpgradeKey = '';
@@ -248,6 +294,13 @@ function renderLog(state: State): void {
`<div class="log-entry ${e.kind}">${e.text}</div>`
).join('');
log.scrollTop = 0;
if (state.log[0]?.kind === 'kaos') {
const app = document.getElementById('app')!;
app.classList.remove('kaos-glitch');
void app.offsetWidth; // restart animation
app.classList.add('kaos-glitch');
}
}
function showOverlay(state: State): void {