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';
|
import { STANDARD_EVENTS, KAOS_EVENTS, type GameEvent } from './events.ts';
|
||||||
|
|
||||||
|
export interface PendingEvent {
|
||||||
|
event: GameEvent;
|
||||||
|
kind: 'event' | 'kaos';
|
||||||
|
}
|
||||||
import { UPGRADES } from './upgrades.ts';
|
import { UPGRADES } from './upgrades.ts';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
@@ -24,6 +29,7 @@ export interface State {
|
|||||||
usedEventIds: Set<string>;
|
usedEventIds: Set<string>;
|
||||||
nextEventTick: number;
|
nextEventTick: number;
|
||||||
nextKaosTick: number;
|
nextKaosTick: number;
|
||||||
|
pendingEvent: PendingEvent | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
@@ -66,6 +72,7 @@ export function createState(): State {
|
|||||||
usedEventIds: new Set(),
|
usedEventIds: new Set(),
|
||||||
nextEventTick: EVENT_INTERVAL_TICKS,
|
nextEventTick: EVENT_INTERVAL_TICKS,
|
||||||
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
|
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) };
|
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||||
s.tick += 1;
|
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
|
// Resource accumulation
|
||||||
s.andakt += s.andaktRate * s.troende * DT;
|
s.andakt += s.andaktRate * s.troende * DT;
|
||||||
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * 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);
|
s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * DT);
|
||||||
|
|
||||||
// Standard event
|
// Standard event
|
||||||
if (s.tick >= s.nextEventTick) {
|
if (!s.pendingEvent && s.tick >= s.nextEventTick) {
|
||||||
const ev = pickEvent(STANDARD_EVENTS, s);
|
const ev = pickEvent(STANDARD_EVENTS, s);
|
||||||
if (ev) {
|
if (ev) {
|
||||||
s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' });
|
|
||||||
applyEffect(s, ev.effect);
|
|
||||||
s.usedEventIds.add(ev.id);
|
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);
|
s.nextEventTick = s.tick + EVENT_INTERVAL_TICKS + Math.floor(Math.random() * 60);
|
||||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kaos event
|
// 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);
|
const ev = pickEvent(KAOS_EVENTS, s);
|
||||||
if (ev) {
|
if (ev) {
|
||||||
s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' });
|
|
||||||
applyEffect(s, ev.effect);
|
|
||||||
s.usedEventIds.add(ev.id);
|
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.kaos = Math.max(0, s.kaos - 80);
|
||||||
s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120);
|
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;
|
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 {
|
interface LocationVisit {
|
||||||
text: string;
|
text: string;
|
||||||
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
|
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ export type EventEffect = Partial<{
|
|||||||
ritual: number;
|
ritual: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export interface EventChoice {
|
||||||
|
text: string;
|
||||||
|
effect: EventEffect;
|
||||||
|
resultText: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameEvent {
|
export interface GameEvent {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
effect: EventEffect;
|
effect: EventEffect;
|
||||||
weight: number;
|
weight: number;
|
||||||
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>;
|
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>;
|
||||||
|
choices?: EventChoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Area = 'rosengard' | 'varnhem' | 'torso';
|
export type Area = 'rosengard' | 'varnhem' | 'torso';
|
||||||
@@ -442,6 +449,81 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
|||||||
weight: 6,
|
weight: 6,
|
||||||
requires: { minDread: 30 },
|
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) ─────────────────────────────────────────────
|
// ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import './style.css';
|
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 { initUI, renderState } from './ui.ts';
|
||||||
import { initMap, updateMap } from './map.ts';
|
import { initMap, updateMap } from './map.ts';
|
||||||
|
|
||||||
@@ -22,7 +22,8 @@ function start(): void {
|
|||||||
visitCooldowns = new Map();
|
visitCooldowns = new Map();
|
||||||
document.getElementById('overlay')!.classList.remove('visible');
|
document.getElementById('overlay')!.classList.remove('visible');
|
||||||
renderState(state);
|
renderState(state);
|
||||||
}
|
},
|
||||||
|
(index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); }
|
||||||
);
|
);
|
||||||
|
|
||||||
initMap('map', (locationId) => {
|
initMap('map', (locationId) => {
|
||||||
|
|||||||
@@ -343,6 +343,18 @@ body {
|
|||||||
gap: 20px;
|
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 { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
.help-section-title {
|
.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 { UPGRADES } from './upgrades.ts';
|
||||||
import { VERSION, CHANGELOG } from './data/version.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 = `
|
app.innerHTML = `
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<div class="brand">DJUPET</div>
|
<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>
|
<div class="overlay-body" id="overlay-body"></div>
|
||||||
<button class="overlay-btn" id="overlay-btn">Börja om</button>
|
<button class="overlay-btn" id="overlay-btn">Börja om</button>
|
||||||
</div>
|
</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('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 helpModal = document.getElementById('help-modal')!;
|
||||||
const helpInner = document.getElementById('help-modal-inner')!;
|
const helpInner = document.getElementById('help-modal-inner')!;
|
||||||
document.getElementById('help-btn')!.addEventListener('click', () => helpModal.classList.toggle('visible'));
|
document.getElementById('help-btn')!.addEventListener('click', () => helpModal.classList.toggle('visible'));
|
||||||
@@ -198,6 +212,31 @@ export function renderState(state: State): void {
|
|||||||
|
|
||||||
// Overlay
|
// Overlay
|
||||||
if (state.phase !== 'playing') showOverlay(state);
|
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 = '';
|
let lastUpgradeKey = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user