Initial commit — Djupet
Idle cult management game set in Malmö, Sweden. Manage Troende, Andakt, Dread, Hemlighet and Kaos while completing the Grand Ritual before investigators shut you down. Lovecraftian flavour, Henderson- tier chaos events, OSM/Leaflet map with dread spread markers across Rosengård, Värnhemstorget, Turning Torso and Øresundsbron. Vanilla TypeScript + Vite, no framework. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
166
src/engine.ts
Normal file
166
src/engine.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { STANDARD_EVENTS, KAOS_EVENTS, type GameEvent } from './events.ts';
|
||||
import { UPGRADES } from './upgrades.ts';
|
||||
|
||||
export interface State {
|
||||
tick: number;
|
||||
troende: number;
|
||||
andakt: number;
|
||||
andaktRate: number; // per second
|
||||
dread: number;
|
||||
dreadRate: number; // per second
|
||||
hemlighet: number; // 0-100, lose at 0
|
||||
kaos: number; // 0-100, triggers events at 100
|
||||
kaosRate: number; // per second
|
||||
ritual: number; // 0-100, win at 100
|
||||
ritualRate: number; // per second
|
||||
ritualUnlocked: boolean;
|
||||
areas: { rosengard: boolean; varnhem: boolean; torso: boolean };
|
||||
upgrades: Set<string>;
|
||||
log: LogEntry[];
|
||||
phase: 'playing' | 'won' | 'lost';
|
||||
lostReason?: string;
|
||||
usedEventIds: Set<string>;
|
||||
nextEventTick: number;
|
||||
nextKaosTick: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
tick: number;
|
||||
text: string;
|
||||
kind: 'event' | 'kaos' | 'system' | 'upgrade';
|
||||
}
|
||||
|
||||
const EVENT_INTERVAL_TICKS = 180; // ~6s at 30fps
|
||||
const KAOS_THRESHOLD = 100;
|
||||
const HEMLIGHET_DRAIN_PER_TROENDE = 0.002; // per second, per troende above 4
|
||||
|
||||
export function createState(): State {
|
||||
return {
|
||||
tick: 0,
|
||||
troende: 1,
|
||||
andakt: 10,
|
||||
andaktRate: 0.3,
|
||||
dread: 0,
|
||||
dreadRate: 0,
|
||||
hemlighet: 100,
|
||||
kaos: 0,
|
||||
kaosRate: 0.05,
|
||||
ritual: 0,
|
||||
ritualRate: 0,
|
||||
ritualUnlocked: false,
|
||||
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',
|
||||
usedEventIds: new Set(),
|
||||
nextEventTick: EVENT_INTERVAL_TICKS,
|
||||
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
|
||||
};
|
||||
}
|
||||
|
||||
function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
|
||||
const available = pool.filter(e => {
|
||||
if (state.usedEventIds.has(e.id)) return false;
|
||||
const r = e.requires;
|
||||
if (!r) return true;
|
||||
if (r.minTroende && state.troende < r.minTroende) return false;
|
||||
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;
|
||||
return true;
|
||||
});
|
||||
if (!available.length) return null;
|
||||
const total = available.reduce((s, e) => s + e.weight, 0);
|
||||
let roll = Math.random() * total;
|
||||
for (const e of available) {
|
||||
roll -= e.weight;
|
||||
if (roll <= 0) return e;
|
||||
}
|
||||
return available[available.length - 1];
|
||||
}
|
||||
|
||||
function applyEffect(state: State, effect: GameEvent['effect']): void {
|
||||
if (effect.andakt) state.andakt = Math.max(0, state.andakt + effect.andakt);
|
||||
if (effect.hemlighet) state.hemlighet = Math.max(0, Math.min(100, state.hemlighet + effect.hemlighet));
|
||||
if (effect.kaos) state.kaos = Math.max(0, Math.min(100, state.kaos + effect.kaos));
|
||||
if (effect.dread) state.dread = Math.max(0, state.dread + effect.dread);
|
||||
if (effect.troende) state.troende = Math.max(1, state.troende + effect.troende);
|
||||
if (effect.ritual && state.ritualUnlocked) state.ritual = Math.min(100, state.ritual + effect.ritual);
|
||||
}
|
||||
|
||||
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) };
|
||||
s.tick += 1;
|
||||
|
||||
// Resource accumulation
|
||||
s.andakt += s.andaktRate * s.troende * DT;
|
||||
s.dread += s.dreadRate * DT;
|
||||
if (s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + s.ritualRate * DT);
|
||||
|
||||
// Kaos accumulation — faster with more troende and more dread
|
||||
const kaosScale = 1 + (s.dread / 200);
|
||||
s.kaos = Math.min(100, s.kaos + s.kaosRate * kaosScale * DT);
|
||||
|
||||
// Hemlighet drain — more believers = harder to stay hidden
|
||||
if (s.troende > 4) {
|
||||
s.hemlighet = Math.max(0, s.hemlighet - HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) * DT);
|
||||
}
|
||||
|
||||
// Standard event
|
||||
if (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);
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
s.kaos = Math.max(0, s.kaos - 80);
|
||||
s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120);
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
}
|
||||
|
||||
// Win / lose checks
|
||||
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' });
|
||||
} else 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' });
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
export function buyUpgrade(state: State, id: string): State {
|
||||
const upgrade = UPGRADES.find(u => u.id === id);
|
||||
if (!upgrade) return state;
|
||||
if (!upgrade.available(state)) return state;
|
||||
if (upgrade.cost.andakt && state.andakt < upgrade.cost.andakt) return state;
|
||||
if (upgrade.cost.dread && state.dread < upgrade.cost.dread) return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||
if (upgrade.cost.andakt) s.andakt -= upgrade.cost.andakt;
|
||||
if (upgrade.cost.dread) s.dread -= upgrade.cost.dread;
|
||||
upgrade.effect(s);
|
||||
if (upgrade.once) s.upgrades.add(id);
|
||||
s.log.unshift({ tick: s.tick, text: `↳ ${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
return s;
|
||||
}
|
||||
Reference in New Issue
Block a user