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:
@@ -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 }>;
|
||||
|
||||
@@ -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) ─────────────────────────────────────────────
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
41
src/ui.ts
41
src/ui.ts
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user