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>
This commit is contained in:
Fredrik Johansson
2026-07-12 23:17:48 +02:00
parent 0efbe9d55b
commit b9561b05a5
5 changed files with 189 additions and 9 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 {
@@ -24,6 +29,7 @@ export interface State {
usedEventIds: Set<string>;
nextEventTick: number;
nextKaosTick: number;
pendingEvent: PendingEvent | null;
}
export interface LogEntry {
@@ -66,6 +72,7 @@ export function createState(): State {
usedEventIds: new Set(),
nextEventTick: EVENT_INTERVAL_TICKS,
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
pendingEvent: null,
};
}
@@ -107,6 +114,12 @@ export function tick(state: State): State {
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
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 +137,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,6 +184,29 @@ 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) };
applyEffect(s, choice.effect);
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;
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;

View File

@@ -7,12 +7,19 @@ export type EventEffect = Partial<{
ritual: number;
}>;
export interface EventChoice {
text: string;
effect: EventEffect;
resultText: string;
}
export interface GameEvent {
id: string;
text: string;
effect: EventEffect;
weight: number;
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>;
choices?: EventChoice[];
}
export type Area = 'rosengard' | 'varnhem' | 'torso';
@@ -442,6 +449,81 @@ 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.',
},
{
text: 'Be honom tiga',
effect: { kaos: 6 },
resultText: 'Ni tystar honom. Han ler ändå, som om han redan sagt det som behövdes.',
},
],
},
{
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: '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

@@ -343,6 +343,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,24 @@ 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="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'));
@@ -198,6 +212,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 = '';