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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
src/*.js
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Djupet</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1120
package-lock.json
generated
Normal file
1120
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "djupet",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5681",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
238
src/events.ts
Normal file
238
src/events.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
export type EventEffect = Partial<{
|
||||
andakt: number;
|
||||
hemlighet: number;
|
||||
kaos: number;
|
||||
dread: number;
|
||||
troende: number;
|
||||
ritual: number;
|
||||
}>;
|
||||
|
||||
export interface GameEvent {
|
||||
id: string;
|
||||
text: string;
|
||||
effect: EventEffect;
|
||||
weight: number;
|
||||
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>;
|
||||
}
|
||||
|
||||
export type Area = 'rosengard' | 'varnhem' | 'torso';
|
||||
|
||||
// ── Standard events ──────────────────────────────────────────────────────────
|
||||
|
||||
export const STANDARD_EVENTS: GameEvent[] = [
|
||||
// Early / low dread
|
||||
{
|
||||
id: 'dreams_lindqvist',
|
||||
text: 'Broder Lindqvist drömmer om havet varje natt. Han säger att fisken talar till honom. Vi lyssnar.',
|
||||
effect: { andakt: 5, kaos: 2 },
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'systembolaget',
|
||||
text: 'En berusad man utanför Systembolaget på Davidshallsgatan erbjöd sig att "hälsa på gubben i havet." Vi är osäkra på om han menar Den Sovande eller sin morfar. Kanske båda.',
|
||||
effect: { kaos: 8, dread: 3 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'skatteverket',
|
||||
text: 'Skatteverket har skickat ett brev. De undrar om vår religiösa verksamhet är momsbefriad. Syster Hassan svarar dem med ett 47 sidor långt svar. De hör inte av sig igen.',
|
||||
effect: { hemlighet: -5, andakt: 10 },
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: 'ducks_torso',
|
||||
text: 'Det finns en anledning till att alla änder vid Ribersborg vänder sig mot Turning Torso i ett nästan tillbedjande tillstånd. De anar mer än oss.',
|
||||
effect: { dread: 8, kaos: 3 },
|
||||
weight: 9,
|
||||
},
|
||||
{
|
||||
id: 'ikea_table',
|
||||
text: 'Ett IKEA-bord av modell LACK har börjat blöda svart vätska. Vi har lagt en matta över det. Det hjälper inte men det ser snyggare ut.',
|
||||
effect: { dread: 6, kaos: 5 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'discord_goran',
|
||||
text: 'En Malmöbo vid namn Göran har av misstag blivit med i vår Discord-server och tror att vi diskuterar ett brädspel om havet. Hans feedback är konstruktiv.',
|
||||
effect: { kaos: 12, hemlighet: -8 },
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'neighbor_smell',
|
||||
text: 'Grannarna har klagat på lukten från källaren. Vi förklarar att det är kimchi. Det är inte kimchi.',
|
||||
effect: { hemlighet: -6, andakt: 4 },
|
||||
weight: 9,
|
||||
},
|
||||
{
|
||||
id: 'lighthouse_keeper',
|
||||
text: 'Fyrvaktaren vid Malmöhus har slutat dyka upp. Hans ersättare rapporterar att vattnet sjunger nu på nätterna. Hon har också slutat dyka upp.',
|
||||
effect: { dread: 10, kaos: 6 },
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: 'oresund_hum',
|
||||
text: 'Øresundsbron vibrerar med en frekvens som inte finns i naturen. Danskarna på andra sidan ringer och klagar. Vi svarar inte.',
|
||||
effect: { dread: 12, hemlighet: -4 },
|
||||
weight: 7,
|
||||
requires: { minDread: 20 },
|
||||
},
|
||||
{
|
||||
id: 'turning_torso_spin',
|
||||
text: 'Turning Torso har börjat rotera igen. Det har inte gjort det på tre år. Arkitektkontoret säger att det är omöjligt. Arkitektkontoret ringer inte mer.',
|
||||
effect: { dread: 20, kaos: 10 },
|
||||
weight: 6,
|
||||
requires: { minDread: 30 },
|
||||
},
|
||||
{
|
||||
id: 'varnhem_pigeons',
|
||||
text: 'Duvorna på Värnhemstorget har upphört att äta. De sitter bara. De tittar. En av dem har för många ögon.',
|
||||
effect: { dread: 15, kaos: 8 },
|
||||
weight: 7,
|
||||
requires: { unlockedArea: 'varnhem' },
|
||||
},
|
||||
{
|
||||
id: 'varnhem_fountain',
|
||||
text: 'Fontänen på Värnhemstorget rinner baklänges sedan tisdag. Kommunen har satt upp ett staket. Folk tar selfies.',
|
||||
effect: { dread: 12, hemlighet: -10, kaos: 5 },
|
||||
weight: 6,
|
||||
requires: { unlockedArea: 'varnhem' },
|
||||
},
|
||||
{
|
||||
id: 'bus_driver',
|
||||
text: 'Bussföraren på linje 5 körde förbi Värnhemstorget tre gånger utan att stanna. Passagerarna märkte det inte. Bussföraren ser nöjd ut.',
|
||||
effect: { dread: 8, kaos: 6 },
|
||||
weight: 8,
|
||||
requires: { unlockedArea: 'varnhem' },
|
||||
},
|
||||
{
|
||||
id: 'investigator_sighting',
|
||||
text: 'En man i grå kostym har stått utanför lokalen i sex timmar. Han tar anteckningar. Broder Persson hälsade vänligt. Mannen vände sig om och gick. Hans skugga gick inte med.',
|
||||
effect: { hemlighet: -15, kaos: 10 },
|
||||
weight: 5,
|
||||
requires: { minDread: 40 },
|
||||
},
|
||||
{
|
||||
id: 'manuscript_dream',
|
||||
text: 'Skriften har börjat skriva sig själv på nätterna. De nya raderna är inte på något känt språk. De är inte på något okänt språk heller egentligen.',
|
||||
effect: { dread: 18, ritual: 3, kaos: 8 },
|
||||
weight: 5,
|
||||
requires: { minRitual: 10 },
|
||||
},
|
||||
{
|
||||
id: 'torso_shadow',
|
||||
text: 'Turning Torsos skugga pekar nu mot havet oavsett var solen står. En student från Malmö Universitet skriver en uppsats om detta. Hon sover inte längre.',
|
||||
effect: { dread: 22, kaos: 12 },
|
||||
weight: 5,
|
||||
requires: { unlockedArea: 'torso' },
|
||||
},
|
||||
{
|
||||
id: 'torso_windows',
|
||||
text: 'Fönstren i Turning Torso lyser nu i ett mönster. Mönstret är ett namn. Vi kan inte läsa det. Inte än.',
|
||||
effect: { dread: 30, ritual: 5, kaos: 15 },
|
||||
weight: 4,
|
||||
requires: { unlockedArea: 'torso', minRitual: 30 },
|
||||
},
|
||||
{
|
||||
id: 'sea_level',
|
||||
text: 'Vattennivån i Öresund har sjunkit 4 centimeter sedan måndag. SMHI rapporterar ett "tekniskt fel i mätinstrumenten." Det är inte ett tekniskt fel.',
|
||||
effect: { dread: 25, kaos: 10 },
|
||||
weight: 4,
|
||||
requires: { minDread: 60 },
|
||||
},
|
||||
{
|
||||
id: 'police_curious',
|
||||
text: 'Polisen i Malmö har börjat fråga om "den religiösa föreningen vid vattnet." Vi skickar dem en broschyr om mindfulness. De tackar och ber om en till.',
|
||||
effect: { hemlighet: -12, andakt: 8 },
|
||||
weight: 5,
|
||||
requires: { minDread: 50 },
|
||||
},
|
||||
{
|
||||
id: 'final_dream',
|
||||
text: 'I natt drömde alla troende samma sak. Det finns inga ord för vad vi såg. Det finns dock ett ljud. Vi har börjat göra det.',
|
||||
effect: { dread: 40, ritual: 8, troende: 2, kaos: 20 },
|
||||
weight: 3,
|
||||
requires: { minRitual: 60 },
|
||||
},
|
||||
];
|
||||
|
||||
// ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
|
||||
|
||||
export const KAOS_EVENTS: GameEvent[] = [
|
||||
{
|
||||
id: 'k_goran_dog',
|
||||
text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantingen. Tvärtom. Bamse leder nu det andra kvartalets böner.',
|
||||
effect: { kaos: -40, andakt: 20, troende: 1 },
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'k_influencer',
|
||||
text: 'En influencer har börjat live-streama utanför vår lokal med titeln "mysig sektkänsla i Malmö 🌊✨". Kommentarerna är överväldigande positiva. Dread ökar snabbare nu.',
|
||||
effect: { kaos: -30, dread: 40, hemlighet: -20 },
|
||||
weight: 9,
|
||||
},
|
||||
{
|
||||
id: 'k_zlatan',
|
||||
text: 'Zlatan Ibrahimović har postat en bild på Instagram från Stortorget klockan 3 på natten med texten "jag vet." Vi vet inte vad han vet. Han vet.',
|
||||
effect: { kaos: -50, dread: 35, hemlighet: -5 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'k_bygglov',
|
||||
text: 'Kommunen har beviljat bygglov för "andlig rekreationsanläggning" vid Västra Hamnen. Ceremonin är nu stadsplanegodkänd. Det finns en toalett.',
|
||||
effect: { kaos: -35, hemlighet: 15, andakt: 25 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'k_journalist',
|
||||
text: 'En journalist från Sydsvenskan skriver ett positivt reportage om "Malmös växande alternativa andlighet." Artikeln vinner pris. Juryn kan inte minnas vad de tyckte om den.',
|
||||
effect: { kaos: -40, troende: 3, hemlighet: -15 },
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: 'k_rival_cult',
|
||||
text: 'En rivaliserande sekt med bas i Hässleholm försöker summera en annan entitet. De summerar fel. De summerar IKEA:s kundtjänst. Det är värre.',
|
||||
effect: { kaos: -45, dread: 50, hemlighet: 10 },
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: 'k_duck_army',
|
||||
text: 'Änderna vid Ribersborg marscherar nu i formation mot Turning Torso. Turisterna filmar det. En orkester spelar av oklar anledning. Det låter rätt.',
|
||||
effect: { kaos: -60, dread: 60, ritual: 5 },
|
||||
weight: 5,
|
||||
requires: { minDread: 30 },
|
||||
},
|
||||
{
|
||||
id: 'k_old_man',
|
||||
text: 'En pensionerad sjöman med oidentifierbara tatueringar har låst in sig i tornet vid Turning Torso och börjat sjunga. Räddningstjänsten kan inte ta sig in. Sången hjälper faktiskt ritualen.',
|
||||
effect: { kaos: -50, ritual: 10, hemlighet: -10 },
|
||||
weight: 5,
|
||||
requires: { unlockedArea: 'torso' },
|
||||
},
|
||||
{
|
||||
id: 'k_varnhem_kebab',
|
||||
text: 'Kebabkiosken på Värnhemstorget serverar sedan onsdag ett alternativt menyalternativ som inte finns på tavlan. Kunderna som beställer det återkommer. De ser annorlunda ut.',
|
||||
effect: { kaos: -40, troende: 4, dread: 30 },
|
||||
weight: 6,
|
||||
requires: { unlockedArea: 'varnhem' },
|
||||
},
|
||||
{
|
||||
id: 'k_denmark',
|
||||
text: 'Danmark har officiellt begärt förklaring om vad som händer i Malmö. Sverige skickar en arbetsgrupp. Arbetsgruppen reser över Øresundsbron och hör inte av sig.',
|
||||
effect: { kaos: -55, dread: 45, hemlighet: -8 },
|
||||
weight: 5,
|
||||
requires: { minDread: 50 },
|
||||
},
|
||||
{
|
||||
id: 'k_goran_promoted',
|
||||
text: 'Göran har frågat om han kan "ta lite mer ansvar" i föreningen. Vi har utnämnt honom till Väktare av det Tredje Sigillet. Han tolkar det som kassör. Det är ungefär rätt.',
|
||||
effect: { kaos: -30, andakt: 30, hemlighet: 5 },
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'k_no_survivors',
|
||||
text: 'Det hände något på Värnhemstorget i natt. Bevakningskamerorna fungerar men visar statiska bilder från 1987. Alla inblandade är okej. Ingen vet vad "okej" betyder längre.',
|
||||
effect: { kaos: -70, dread: 80, ritual: 8 },
|
||||
weight: 3,
|
||||
requires: { minDread: 60, unlockedArea: 'varnhem' },
|
||||
},
|
||||
];
|
||||
41
src/main.ts
Normal file
41
src/main.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import './style.css';
|
||||
import { createState, tick, buyUpgrade } from './engine.ts';
|
||||
import { initUI, renderState } from './ui.ts';
|
||||
import { initMap, updateMap } from './map.ts';
|
||||
|
||||
let state = createState();
|
||||
let lastTime = 0;
|
||||
const TARGET_FPS = 30;
|
||||
const FRAME_MS = 1000 / TARGET_FPS;
|
||||
|
||||
function start(): void {
|
||||
const app = document.getElementById('app')!;
|
||||
|
||||
initUI(app,
|
||||
(id) => { state = buyUpgrade(state, id); renderState(state); },
|
||||
() => {
|
||||
state = createState();
|
||||
document.getElementById('overlay')!.classList.remove('visible');
|
||||
renderState(state);
|
||||
}
|
||||
);
|
||||
|
||||
initMap('map');
|
||||
renderState(state);
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function loop(now: number): void {
|
||||
if (now - lastTime >= FRAME_MS) {
|
||||
lastTime = now;
|
||||
if (state.phase === 'playing') {
|
||||
state = tick(state);
|
||||
renderState(state);
|
||||
updateMap(state.dread, state.areas);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
start();
|
||||
85
src/map.ts
Normal file
85
src/map.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Leaflet is loaded via CDN in index.html
|
||||
// @ts-expect-error - Leaflet global
|
||||
const L = window.L;
|
||||
|
||||
export interface MapMarker {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
dreadLevel: number; // 0-3
|
||||
}
|
||||
|
||||
const LOCATIONS: Omit<MapMarker, 'dreadLevel'>[] = [
|
||||
{ id: 'rosengard', lat: 55.5894, lng: 13.0414, label: 'Källaren i Rosengård' },
|
||||
{ id: 'varnhem', lat: 55.5982, lng: 13.0058, label: 'Värnhemstorget' },
|
||||
{ id: 'torso', lat: 55.6130, lng: 12.9758, label: 'Turning Torso' },
|
||||
{ id: 'oresund', lat: 55.5789, lng: 12.9200, label: 'Øresundsbron' },
|
||||
{ id: 'ribersborg',lat: 55.6063, lng: 12.9652, label: 'Ribersborgsstranden' },
|
||||
{ id: 'davidshall',lat: 55.5963, lng: 13.0021, label: 'Davidshallsgatan' },
|
||||
{ id: 'stortorget',lat: 55.6047, lng: 13.0038, label: 'Stortorget' },
|
||||
];
|
||||
|
||||
type LeafletMap = ReturnType<typeof L.map>;
|
||||
type LeafletMarker = ReturnType<typeof L.circleMarker>;
|
||||
|
||||
let map: LeafletMap | null = null;
|
||||
const markers = new Map<string, LeafletMarker>();
|
||||
|
||||
function dreadColor(level: number): string {
|
||||
return ['#1a1a2e', '#4a1a4a', '#8b0a8b', '#cc00cc'][Math.min(level, 3)];
|
||||
}
|
||||
|
||||
function dreadRadius(level: number): number {
|
||||
return [6, 10, 16, 24][Math.min(level, 3)];
|
||||
}
|
||||
|
||||
export function initMap(containerId: string): void {
|
||||
if (!L) return;
|
||||
map = L.map(containerId, {
|
||||
center: [55.597, 13.002],
|
||||
zoom: 12,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
for (const loc of LOCATIONS) {
|
||||
const m = L.circleMarker([loc.lat, loc.lng], {
|
||||
radius: 6,
|
||||
color: '#1a1a2e',
|
||||
fillColor: '#1a1a2e',
|
||||
fillOpacity: 0.3,
|
||||
weight: 1,
|
||||
}).addTo(map).bindTooltip(loc.label, { className: 'map-tooltip', permanent: false });
|
||||
markers.set(loc.id, m);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateMap(dread: number, areas: { rosengard: boolean; varnhem: boolean; torso: boolean }): void {
|
||||
if (!map) return;
|
||||
|
||||
const levels: Record<string, number> = {
|
||||
rosengard: areas.rosengard ? Math.min(3, Math.floor(dread / 40) + 1) : Math.min(1, Math.floor(dread / 60)),
|
||||
varnhem: areas.varnhem ? Math.min(3, Math.floor(dread / 35) + 1) : 0,
|
||||
torso: areas.torso ? 3 : Math.min(1, Math.floor(dread / 80)),
|
||||
oresund: Math.min(2, Math.floor(dread / 50)),
|
||||
ribersborg: Math.min(2, Math.floor(dread / 45)),
|
||||
davidshall: Math.min(1, Math.floor(dread / 70)),
|
||||
stortorget: Math.min(2, Math.floor(dread / 55)),
|
||||
};
|
||||
|
||||
for (const [id, marker] of markers) {
|
||||
const level = levels[id] ?? 0;
|
||||
marker.setStyle({
|
||||
radius: dreadRadius(level),
|
||||
color: dreadColor(level),
|
||||
fillColor: dreadColor(level),
|
||||
fillOpacity: 0.15 + level * 0.2,
|
||||
weight: level > 0 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
276
src/style.css
Normal file
276
src/style.css
Normal file
@@ -0,0 +1,276 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #06060e;
|
||||
--panel: #0a0a16;
|
||||
--border: #1a1a2e;
|
||||
--text: #c8c8d8;
|
||||
--dim: #4a4a6a;
|
||||
--accent: #7a44aa; /* eldritch purple */
|
||||
--dread: #cc44cc; /* dread pink */
|
||||
--kaos: #ff6600; /* chaos orange */
|
||||
--safe: #228844; /* hemlighet green */
|
||||
--danger: #cc2222; /* investigator red */
|
||||
--ritual: #4488ff; /* ritual blue */
|
||||
--win: #00e87a;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { background: var(--bg); }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 280px;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 100vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
#header {
|
||||
grid-column: 1 / -1;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
color: var(--dread);
|
||||
text-shadow: 0 0 20px rgba(204, 68, 204, 0.4);
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Panels ── */
|
||||
.panel {
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel:last-child { border-right: none; border-left: 1px solid var(--border); }
|
||||
|
||||
.panel-title {
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Resources ── */
|
||||
.resource {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.resource-label {
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.resource-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resource-bar {
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
margin-top: 4px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-hemlighet .resource-bar-fill { background: var(--safe); }
|
||||
.bar-kaos .resource-bar-fill { background: var(--kaos); }
|
||||
.bar-ritual .resource-bar-fill { background: var(--ritual); }
|
||||
|
||||
/* ── Map ── */
|
||||
#map-container {
|
||||
background: var(--panel);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Leaflet overrides */
|
||||
.leaflet-container { background: #06060e !important; }
|
||||
.map-tooltip {
|
||||
background: var(--panel) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
color: var(--text) !important;
|
||||
font-family: inherit !important;
|
||||
font-size: 11px !important;
|
||||
padding: 3px 8px !important;
|
||||
border-radius: 2px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.map-tooltip::before { display: none !important; }
|
||||
|
||||
/* ── Upgrades ── */
|
||||
.upgrade-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upgrade-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--dread);
|
||||
}
|
||||
|
||||
.upgrade-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upgrade-btn .up-name {
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.upgrade-btn .up-desc {
|
||||
color: var(--dim);
|
||||
font-size: 10px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upgrade-btn .up-cost { font-size: 10px; color: var(--dim); }
|
||||
|
||||
/* ── Log ── */
|
||||
#log {
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #0e0e1a;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.log-entry.kaos { color: var(--kaos); border-left: 2px solid var(--kaos); }
|
||||
.log-entry.system { color: var(--dim); font-style: italic; }
|
||||
.log-entry.upgrade { color: var(--accent); }
|
||||
|
||||
/* ── Overlays ── */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 6, 14, 0.92);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#overlay.visible { display: flex; }
|
||||
|
||||
.overlay-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.overlay-title.won { color: var(--win); text-shadow: 0 0 30px rgba(0,232,122,0.5); }
|
||||
.overlay-title.lost { color: var(--danger); }
|
||||
|
||||
.overlay-body {
|
||||
max-width: 480px;
|
||||
color: var(--dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.overlay-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--dim);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 24px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.overlay-btn:hover { border-color: var(--accent); color: var(--dread); }
|
||||
|
||||
/* ── Phase label ── */
|
||||
.phase-tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.phase-tag.early { color: var(--dim); border-color: var(--dim); }
|
||||
.phase-tag.mid { color: var(--accent); border-color: var(--accent); }
|
||||
.phase-tag.late { color: var(--dread); border-color: var(--dread); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#app { grid-template-columns: 1fr; }
|
||||
.panel { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
#map { min-height: 250px; }
|
||||
}
|
||||
214
src/ui.ts
Normal file
214
src/ui.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { State } from './engine.ts';
|
||||
import { UPGRADES } from './upgrades.ts';
|
||||
|
||||
export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRestart: () => void): void {
|
||||
app.innerHTML = `
|
||||
<div id="header">
|
||||
<div class="brand">DJUPET</div>
|
||||
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-left">
|
||||
<div class="panel-title">Resurser</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="resource-label">Troende</div>
|
||||
<div class="resource-value" id="r-troende">1</div>
|
||||
</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="resource-label">Andakt <span id="r-andakt-rate" style="color:var(--dim);font-size:10px;"></span></div>
|
||||
<div class="resource-value" id="r-andakt">0</div>
|
||||
</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="resource-label">Dread <span id="r-dread-rate" style="color:var(--dread);font-size:10px;"></span></div>
|
||||
<div class="resource-value" style="color:var(--dread);" id="r-dread">0</div>
|
||||
</div>
|
||||
|
||||
<div class="resource bar-hemlighet" style="margin-top:16px;">
|
||||
<div class="resource-label">Hemlighet</div>
|
||||
<div class="resource-value" id="r-hemlighet">100</div>
|
||||
<div class="resource-bar"><div class="resource-bar-fill" id="bar-hemlighet" style="width:100%"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="resource bar-kaos">
|
||||
<div class="resource-label">Kaos</div>
|
||||
<div class="resource-value" id="r-kaos">0</div>
|
||||
<div class="resource-bar"><div class="resource-bar-fill" id="bar-kaos" style="width:0%"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="resource bar-ritual" id="ritual-section" style="display:none;">
|
||||
<div class="resource-label">Ritual</div>
|
||||
<div class="resource-value" style="color:var(--ritual);" id="r-ritual">0</div>
|
||||
<div class="resource-bar"><div class="resource-bar-fill" id="bar-ritual" style="width:0%"></div></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;">
|
||||
<div class="panel-title">Åtgärder</div>
|
||||
<div id="upgrades"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container">
|
||||
<div id="map"></div>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-right">
|
||||
<div class="panel-title">Aktiva områden</div>
|
||||
<div id="areas" style="font-size:11px;color:var(--dim);line-height:2;">
|
||||
<div id="area-rosengard">○ Rosengård</div>
|
||||
<div id="area-varnhem">○ Värnhemstorget</div>
|
||||
<div id="area-torso">○ Turning Torso</div>
|
||||
</div>
|
||||
<div style="margin-top:20px;" id="investigator-section">
|
||||
<div class="panel-title">Utredarna</div>
|
||||
<div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="overlay">
|
||||
<div class="overlay-title" id="overlay-title"></div>
|
||||
<div class="overlay-body" id="overlay-body"></div>
|
||||
<button class="overlay-btn" id="overlay-btn">Börja om</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
|
||||
|
||||
// Split the map container: map on top, log below
|
||||
const mapContainer = document.getElementById('map-container')!;
|
||||
mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;';
|
||||
const mapEl = document.getElementById('map')!;
|
||||
mapEl.style.cssText = 'flex:0 0 45%;min-height:0;';
|
||||
const logEl = document.getElementById('log')!;
|
||||
logEl.style.cssText = 'flex:1;overflow-y:auto;border-top:1px solid var(--border);';
|
||||
|
||||
// Upgrade click delegation
|
||||
document.getElementById('upgrades')!.addEventListener('click', e => {
|
||||
const btn = (e.target as Element).closest('[data-upgrade]') as HTMLElement | null;
|
||||
if (btn?.dataset['upgrade']) onUpgrade(btn.dataset['upgrade']);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderState(state: State): void {
|
||||
setText('r-troende', String(state.troende));
|
||||
setText('r-andakt', fmt(state.andakt));
|
||||
setText('r-andakt-rate', `+${(state.andaktRate * state.troende).toFixed(1)}/s`);
|
||||
setText('r-dread', fmt(state.dread));
|
||||
setText('r-dread-rate', state.dreadRate > 0 ? `+${state.dreadRate.toFixed(1)}/s` : '');
|
||||
setText('r-hemlighet', Math.floor(state.hemlighet).toString());
|
||||
setText('r-kaos', Math.floor(state.kaos).toString());
|
||||
setBar('bar-hemlighet', state.hemlighet);
|
||||
setBar('bar-kaos', state.kaos);
|
||||
|
||||
const ritualSection = document.getElementById('ritual-section')!;
|
||||
if (state.ritualUnlocked) {
|
||||
ritualSection.style.display = '';
|
||||
setText('r-ritual', Math.floor(state.ritual).toString());
|
||||
setBar('bar-ritual', state.ritual);
|
||||
}
|
||||
|
||||
// Phase tag
|
||||
const phaseEl = document.getElementById('phase-tag')!;
|
||||
if (state.ritual >= 50) {
|
||||
phaseEl.textContent = 'Sent skede'; phaseEl.className = 'phase-tag late';
|
||||
} else if (state.dread >= 30) {
|
||||
phaseEl.textContent = 'Mitt skede'; phaseEl.className = 'phase-tag mid';
|
||||
} else {
|
||||
phaseEl.textContent = 'Tidigt skede'; phaseEl.className = 'phase-tag early';
|
||||
}
|
||||
|
||||
// Areas
|
||||
setArea('area-rosengard', state.areas.rosengard, 'Rosengård');
|
||||
setArea('area-varnhem', state.areas.varnhem, 'Värnhemstorget');
|
||||
setArea('area-torso', state.areas.torso, 'Turning Torso');
|
||||
|
||||
// Investigator status
|
||||
const inv = document.getElementById('investigator-status')!;
|
||||
if (state.hemlighet < 20) {
|
||||
inv.textContent = 'Nära — väldigt nära'; inv.style.color = 'var(--danger)';
|
||||
} else if (state.hemlighet < 50) {
|
||||
inv.textContent = 'Missstänksamma'; inv.style.color = 'var(--kaos)';
|
||||
} else {
|
||||
inv.textContent = 'Inaktiva'; inv.style.color = 'var(--safe)';
|
||||
}
|
||||
|
||||
// Upgrades
|
||||
renderUpgrades(state);
|
||||
|
||||
// Log
|
||||
renderLog(state);
|
||||
|
||||
// Overlay
|
||||
if (state.phase !== 'playing') showOverlay(state);
|
||||
}
|
||||
|
||||
function renderUpgrades(state: State): void {
|
||||
const container = document.getElementById('upgrades')!;
|
||||
const available = UPGRADES.filter(u => u.available(state) && (u.once ? !state.upgrades.has(u.id) : true));
|
||||
|
||||
if (!available.length) {
|
||||
container.innerHTML = '<div style="font-size:11px;color:var(--dim);">Inga tillgängliga åtgärder.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = available.map(u => {
|
||||
const canAfford = (!u.cost.andakt || state.andakt >= u.cost.andakt) && (!u.cost.dread || state.dread >= u.cost.dread);
|
||||
const costs = [
|
||||
u.cost.andakt ? `${u.cost.andakt} andakt` : '',
|
||||
u.cost.dread ? `${u.cost.dread} dread` : '',
|
||||
].filter(Boolean).join(', ');
|
||||
return `<button class="upgrade-btn" data-upgrade="${u.id}" ${canAfford ? '' : 'disabled'}>
|
||||
<span class="up-name">${u.name}</span>
|
||||
<span class="up-desc">${u.desc}</span>
|
||||
<span class="up-cost">Kostnad: ${costs || '—'}</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderLog(state: State): void {
|
||||
const log = document.getElementById('log')!;
|
||||
log.innerHTML = state.log.map(e =>
|
||||
`<div class="log-entry ${e.kind}">${e.text}</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function showOverlay(state: State): void {
|
||||
const ov = document.getElementById('overlay')!;
|
||||
ov.classList.add('visible');
|
||||
const title = document.getElementById('overlay-title')!;
|
||||
const body = document.getElementById('overlay-body')!;
|
||||
if (state.phase === 'won') {
|
||||
title.textContent = 'FULLBORDAT';
|
||||
title.className = 'overlay-title won';
|
||||
body.textContent = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.';
|
||||
} else {
|
||||
title.textContent = 'AVSLÖJAD';
|
||||
title.className = 'overlay-title lost';
|
||||
body.textContent = state.lostReason ?? 'Utredarna kom.';
|
||||
}
|
||||
}
|
||||
|
||||
function setText(id: string, val: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function setBar(id: string, pct: number): void {
|
||||
const el = document.getElementById(id) as HTMLElement | null;
|
||||
if (el) el.style.width = `${Math.min(100, Math.max(0, pct))}%`;
|
||||
}
|
||||
|
||||
function setArea(id: string, active: boolean, label: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.textContent = `${active ? '●' : '○'} ${label}`;
|
||||
el.style.color = active ? 'var(--dread)' : 'var(--dim)';
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Math.floor(n).toLocaleString('sv-SE');
|
||||
}
|
||||
86
src/upgrades.ts
Normal file
86
src/upgrades.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { State } from './engine.ts';
|
||||
|
||||
export interface Upgrade {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
cost: Partial<{ andakt: number; dread: number }>;
|
||||
effect: (s: State) => void;
|
||||
available: (s: State) => boolean;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
export const UPGRADES: Upgrade[] = [
|
||||
{
|
||||
id: 'recruit_neighbor',
|
||||
name: 'Rekrytera en granne',
|
||||
desc: '+1 Troende. De undrade varför källarlamporna flimrade. Nu vet de.',
|
||||
cost: { andakt: 20 },
|
||||
effect: s => { s.troende += 1; },
|
||||
available: s => !s.upgrades.has('first_meeting'),
|
||||
once: false,
|
||||
},
|
||||
{
|
||||
id: 'first_meeting',
|
||||
name: 'Källarmöte i Rosengård',
|
||||
desc: 'Formalisera mötena. +1 andakt/s. Det luktar lite konstigt men ingen säger något.',
|
||||
cost: { andakt: 30 },
|
||||
effect: s => { s.andaktRate += 1; s.areas.rosengard = true; },
|
||||
available: s => s.troende >= 2 && !s.upgrades.has('first_meeting'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'the_manuscript',
|
||||
name: 'Översätt Skriften',
|
||||
desc: 'Någon hittade ett dokument i en källarlokal vid Davidshallsgatan. Texten översätts bäst som "snälla sluta." Vi fortsätter.',
|
||||
cost: { andakt: 80 },
|
||||
effect: s => { s.dreadRate += 0.5; s.ritualUnlocked = true; },
|
||||
available: s => s.troende >= 3 && s.upgrades.has('first_meeting') && !s.upgrades.has('the_manuscript'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'idol_under_bridge',
|
||||
name: 'Idolen under bron',
|
||||
desc: 'Vi placerade en idol under Øresundsbron vid nattens mörkaste timme. Danskarna la märke till det. De säger ingenting.',
|
||||
cost: { andakt: 120, dread: 20 },
|
||||
effect: s => { s.dreadRate += 1; s.andaktRate += 0.5; },
|
||||
available: s => s.upgrades.has('the_manuscript') && !s.upgrades.has('idol_under_bridge'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'varnhem_circle',
|
||||
name: 'Cirkeln på Värnhemstorget',
|
||||
desc: 'Rita cirkeln. Använd krita. Det är viktigt att det är krita. Kommunen tvättar bort den varje morgon. Den är tillbaka varje kväll.',
|
||||
cost: { andakt: 200, dread: 40 },
|
||||
effect: s => { s.areas.varnhem = true; s.dreadRate += 1.5; s.kaosRate += 0.2; },
|
||||
available: s => s.upgrades.has('idol_under_bridge') && s.dread >= 50 && !s.upgrades.has('varnhem_circle'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'recruit_2',
|
||||
name: 'Rekrytera ytterligare troende',
|
||||
desc: '+2 Troende. Ryktet sprids längs de rätta kanalerna.',
|
||||
cost: { andakt: 150 },
|
||||
effect: s => { s.troende += 2; s.andaktRate += 0.5; },
|
||||
available: s => s.upgrades.has('first_meeting') && s.troende < 8,
|
||||
once: false,
|
||||
},
|
||||
{
|
||||
id: 'torso_beacon',
|
||||
name: 'Altaret vid Turning Torso',
|
||||
desc: 'Byggnaden har alltid lyssnat. Nu ber vi den svara. Den gör det.',
|
||||
cost: { andakt: 400, dread: 100 },
|
||||
effect: s => { s.areas.torso = true; s.dreadRate += 3; s.ritualRate += 0.5; },
|
||||
available: s => s.upgrades.has('varnhem_circle') && s.dread >= 100 && !s.upgrades.has('torso_beacon'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'grand_ritual_prep',
|
||||
name: 'Förberedelser för Den Stora Ritualen',
|
||||
desc: 'Allt har lett hit. Turning Torso pekar. Värnhemstorget väntar. Öresund drar sig tillbaka.',
|
||||
cost: { andakt: 600, dread: 200 },
|
||||
effect: s => { s.ritualRate += 2; s.kaosRate += 0.5; },
|
||||
available: s => s.upgrades.has('torso_beacon') && s.ritual >= 50 && !s.upgrades.has('grand_ritual_prep'),
|
||||
once: true,
|
||||
},
|
||||
];
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
2
vite.config.ts
Normal file
2
vite.config.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { defineConfig } from 'vite';
|
||||
export default defineConfig({ server: { port: 5681 } });
|
||||
Reference in New Issue
Block a user