Initial commit: Thön, a Vildhjarta-inspired idle game
Idle/incremental game about a small circle of "bärare" weaving open a breach to Thön from a disused sawmill in Skellefteå, Sweden. Reskins the Djupet engine (passive resources, map-click visits, escalating events, win/lose meters) around Vildhjarta's mythology — masks, the Thörnepherian language, and a discoverable lore codex. Vanilla TypeScript + Vite + Leaflet, with generated key art, location art, and character portraits under public/img/.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
export interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
bio: string;
|
||||
unlockEventId?: string;
|
||||
img: string;
|
||||
}
|
||||
|
||||
export const CHARACTERS: Character[] = [
|
||||
{
|
||||
id: 'einar',
|
||||
name: 'Einar',
|
||||
role: 'Grundare. Hittade kvarnen.',
|
||||
bio: 'Drömmer om älven varje natt, mer och mer bokstavligt ju djupare bräschen blir. Ritar samma tornsilhuett om och om igen, alltid samma vinkel. Sover allt mindre. Säger att han inte längre behöver det.',
|
||||
img: '/img/characters/einar.png',
|
||||
},
|
||||
{
|
||||
id: 'signe',
|
||||
name: 'Signe',
|
||||
role: 'Grundare. Sköter det praktiska.',
|
||||
bio: 'Ordnar bygglov och elavtal för en fastighet ingen äger på papperet, och vinner varje gång. Obekymrad, orubblig — tills hon börjar föreslå ändringar i väven med en säkerhet ingen minns att hon haft förut.',
|
||||
img: '/img/characters/signe.png',
|
||||
},
|
||||
{
|
||||
id: 'erik',
|
||||
name: 'Erik',
|
||||
role: 'Kom av misstag. Stannade av eget val.',
|
||||
bio: 'Lades till i fel Discord-server och trodde ni höll på med ett brädspel om vikingar. Feedbacken var konstig nog att han fick stanna. Numera Väktare av Andra Tråden, vilket han tolkar ungefär som kassör.',
|
||||
unlockEventId: 'discord_erik',
|
||||
img: '/img/characters/erik.png',
|
||||
},
|
||||
{
|
||||
id: 'bo_henrik',
|
||||
name: 'Bo-Henrik från Ursviken',
|
||||
role: 'Köper samma tidning varje dag. Vet mer än han säger.',
|
||||
bio: 'Köper en namnlös tidning på macken varje morgon och verkar nöjd med den. Visade sig senare tillhöra en helt annan grupp sedan 80-talet — vars egna rötter går tillbaka till sågverkstiden. Samordningsmötet gick förvånansvärt bra.',
|
||||
unlockEventId: 'sawmill_ledger',
|
||||
img: '/img/characters/bo-henrik.png',
|
||||
},
|
||||
{
|
||||
id: 'jonna',
|
||||
name: 'Jonna från Anderstorp',
|
||||
role: 'Var med i tre veckor.',
|
||||
bio: 'Slutade komma till mötena. Skorna hittades vid älvkanten, ordentligt placerade, tårna mot vattnet. Hennes mask, den hon aldrig sagt ja till att bära, låg prydligt lagd bredvid.',
|
||||
unlockEventId: 'discord_erik_missing',
|
||||
img: '/img/characters/jonna.png',
|
||||
},
|
||||
{
|
||||
id: 'maja',
|
||||
name: 'Systern Maja',
|
||||
role: 'Talar i sömnen. Inte på svenska.',
|
||||
bio: 'Talar i sömnen på ett språk ingen kände igen — tills ni lärde er Thörnepherian, och det plötsligt var uppenbart. Inspelningen, uppspelad baklänges, var lika obegriplig, fast på ett annat sätt.',
|
||||
unlockEventId: 'text_language',
|
||||
img: '/img/characters/maja.png',
|
||||
},
|
||||
{
|
||||
id: 'timmerman',
|
||||
name: 'Den gamle timmermannen',
|
||||
role: 'Vid Sara kulturhus. Lyssnar.',
|
||||
bio: 'Pensionerad byggnadsarbetare, med ärr ingen kan förklara ursprunget till. Sitter vid tornets fot varje kväll och lyssnar mot väggen. Räddningstjänsten har slutat oroa sig för honom. Lyssnandet hjälper faktiskt väven.',
|
||||
unlockEventId: 'sara_hum',
|
||||
img: '/img/characters/timmerman.png',
|
||||
},
|
||||
{
|
||||
id: 'doktorand',
|
||||
name: 'Doktoranden',
|
||||
role: 'Översatte tre rader. Ångrade sig inte.',
|
||||
bio: 'Råkade översätta tre rader Thörnepherian för ett annat projekt vid Campus. Sökte senare medlemskap och sa att hon "bara vill se hur det slutar." Ni tror henne inte. Ni släpper in henne ändå.',
|
||||
unlockEventId: 'researcher_joins',
|
||||
img: '/img/characters/doktorand.png',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
export const VERSION = '0.1.0';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.1.0',
|
||||
date: '2026-08-08',
|
||||
changes: [
|
||||
'Kvarnen vid Skellefteälven grundades',
|
||||
'Kartan över Skellefteå aktiverades — Sara kulturhus, Bonnstan, Älvkajen',
|
||||
'Väv-systemet implementerat: sken, bräsch, fäste, yra',
|
||||
'Erik från Discord kom med av misstag och är fortfarande kvar',
|
||||
'Thön är ännu inte här',
|
||||
],
|
||||
},
|
||||
];
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
import { STANDARD_EVENTS, YRA_EVENTS, type GameEvent } from './events.ts';
|
||||
|
||||
export interface PendingEvent {
|
||||
event: GameEvent;
|
||||
kind: 'event' | 'yra';
|
||||
}
|
||||
import { UPGRADES } from './upgrades.ts';
|
||||
|
||||
export interface State {
|
||||
tick: number;
|
||||
barare: number; // carriers — those who wear the mask willingly
|
||||
sken: number;
|
||||
skenRate: number; // per second
|
||||
eko: number;
|
||||
ekoRate: number; // multiplier on the base network-effect curve
|
||||
brasch: number; // the breach — how far Thön has bled through
|
||||
braschRate: number; // per second
|
||||
faste: number; // 0-100, grip on this side — lose at 0
|
||||
yra: number; // 0-100, triggers chaotic events at 100
|
||||
yraRate: number; // per second
|
||||
vav: number; // 0-100, complete the Weave to win
|
||||
vavRate: number; // per second
|
||||
vavUnlocked: boolean;
|
||||
areas: { kvarnen: boolean; bonnstan: boolean; sara: boolean };
|
||||
upgrades: Set<string>;
|
||||
log: LogEntry[];
|
||||
phase: 'intro' | 'playing' | 'won' | 'lost' | 'abandoned';
|
||||
lostReason?: string;
|
||||
usedEventIds: Set<string>;
|
||||
flags: Set<string>;
|
||||
nextEventTick: number;
|
||||
nextYraTick: number;
|
||||
pendingEvent: PendingEvent | null;
|
||||
logSeq: number;
|
||||
lastRealTime: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
tick: number;
|
||||
text: string;
|
||||
kind: 'event' | 'yra' | 'system' | 'upgrade';
|
||||
}
|
||||
|
||||
const SAVE_VERSION = '001';
|
||||
const SAVE_KEY = `thon_v${SAVE_VERSION}`;
|
||||
|
||||
const EVENT_INTERVAL_TICKS = 180; // ~6s at 30fps
|
||||
const YRA_THRESHOLD = 100;
|
||||
const FASTE_DRAIN_PER_BARARE = 0.002; // per second, per bärare above 4
|
||||
const BRASCH_FASTE_DRAIN = 0.35; // per second, saturating with brasch
|
||||
const VAV_FASTE_DRAIN = 0.25; // per second at vav=100 — a finished weave is hard to hide
|
||||
const EKO_BASE_RATE = 0.15; // per second, scales with barare^1.2
|
||||
|
||||
export function createState(): State {
|
||||
return {
|
||||
tick: 0,
|
||||
barare: 1,
|
||||
sken: 10,
|
||||
skenRate: 0.3,
|
||||
eko: 0,
|
||||
ekoRate: 1,
|
||||
brasch: 0,
|
||||
braschRate: 0,
|
||||
faste: 100,
|
||||
yra: 0,
|
||||
yraRate: 0.05,
|
||||
vav: 0,
|
||||
vavRate: 0,
|
||||
vavUnlocked: false,
|
||||
areas: { kvarnen: false, bonnstan: false, sara: false },
|
||||
upgrades: new Set(),
|
||||
log: [{ tick: 0, text: 'Kvarnen har stått tom sedan sågverket lades ner. Ni är fem. En av er räknade fel, eller så räknade ni rätt och det är en av er som inte borde räknas.', kind: 'system' }],
|
||||
phase: 'intro',
|
||||
usedEventIds: new Set(),
|
||||
flags: new Set(),
|
||||
nextEventTick: EVENT_INTERVAL_TICKS,
|
||||
nextYraTick: EVENT_INTERVAL_TICKS * 3,
|
||||
pendingEvent: null,
|
||||
logSeq: 0,
|
||||
lastRealTime: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function startGame(state: State): State {
|
||||
if (state.phase !== 'intro') return state;
|
||||
return { ...state, phase: 'playing' };
|
||||
}
|
||||
|
||||
export function abandonGame(state: State): State {
|
||||
const s = { ...state, log: [...state.log] };
|
||||
s.phase = 'abandoned';
|
||||
pushLog(s, { tick: s.tick, text: 'Ni går ut ur kvarnen och stänger dörren. Ingen låser den. Det finns inget lås som skulle hålla i det här. Thön väntar. Det har alltid väntat.', kind: 'system' });
|
||||
return s;
|
||||
}
|
||||
|
||||
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.minBarare && state.barare < r.minBarare) return false;
|
||||
if (r.minBrasch && state.brasch < r.minBrasch) return false;
|
||||
if (r.minVav && state.vav < r.minVav) return false;
|
||||
if (r.unlockedArea && !state.areas[r.unlockedArea]) return false;
|
||||
if (r.flag && !state.flags.has(r.flag)) return false;
|
||||
if (r.completedEventId && !state.usedEventIds.has(r.completedEventId)) 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 pushLog(state: State, entry: LogEntry): void {
|
||||
state.log.unshift(entry);
|
||||
state.logSeq += 1;
|
||||
if (state.log.length > 40) state.log = state.log.slice(0, 40);
|
||||
}
|
||||
|
||||
function applyEffect(state: State, effect: GameEvent['effect']): void {
|
||||
if (effect.sken) state.sken = Math.max(0, state.sken + effect.sken);
|
||||
if (effect.faste) state.faste = Math.max(0, Math.min(100, state.faste + effect.faste));
|
||||
if (effect.yra) state.yra = Math.max(0, Math.min(100, state.yra + effect.yra));
|
||||
if (effect.brasch) state.brasch = Math.max(0, state.brasch + effect.brasch);
|
||||
if (effect.barare) state.barare = Math.max(1, state.barare + effect.barare);
|
||||
if (effect.vav && state.vavUnlocked) state.vav = Math.min(100, state.vav + effect.vav);
|
||||
}
|
||||
|
||||
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), flags: new Set(state.flags) };
|
||||
s.tick += 1;
|
||||
|
||||
if (s.pendingEvent) {
|
||||
s.nextEventTick += 1;
|
||||
s.nextYraTick += 1;
|
||||
}
|
||||
|
||||
s.sken += s.skenRate * s.barare * DT;
|
||||
s.eko += EKO_BASE_RATE * s.ekoRate * Math.pow(s.barare, 1.2) * DT;
|
||||
s.brasch += s.braschRate * DT;
|
||||
if (s.vavUnlocked) s.vav = Math.min(100, s.vav + s.vavRate * DT);
|
||||
|
||||
const yraScale = 1 + (s.brasch / 200);
|
||||
s.yra = Math.min(100, s.yra + s.yraRate * yraScale * DT);
|
||||
|
||||
const barareDrain = s.barare > 4 ? FASTE_DRAIN_PER_BARARE * (s.barare - 4) : 0;
|
||||
const braschDrain = BRASCH_FASTE_DRAIN * (s.brasch / (s.brasch + 150));
|
||||
const vavDrain = s.vavUnlocked ? VAV_FASTE_DRAIN * (s.vav / 100) : 0;
|
||||
s.faste = Math.max(0, s.faste - (barareDrain + braschDrain + vavDrain) * DT);
|
||||
|
||||
if (!s.pendingEvent && s.tick >= s.nextEventTick) {
|
||||
const ev = pickEvent(STANDARD_EVENTS, s);
|
||||
if (ev) {
|
||||
s.usedEventIds.add(ev.id);
|
||||
if (ev.choices?.length) {
|
||||
pushLog(s, { tick: s.tick, text: ev.text, kind: 'event' });
|
||||
s.pendingEvent = { event: ev, kind: 'event' };
|
||||
} else {
|
||||
pushLog(s, { 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.pendingEvent && (s.yra >= YRA_THRESHOLD || s.tick >= s.nextYraTick)) {
|
||||
const ev = pickEvent(YRA_EVENTS, s);
|
||||
if (ev) {
|
||||
if (ev.choices?.length) {
|
||||
pushLog(s, { tick: s.tick, text: `⚡ ${ev.text}`, kind: 'yra' });
|
||||
s.pendingEvent = { event: ev, kind: 'yra' };
|
||||
} else {
|
||||
pushLog(s, { tick: s.tick, text: `⚡ ${ev.text}`, kind: 'yra' });
|
||||
applyEffect(s, ev.effect);
|
||||
}
|
||||
}
|
||||
s.yra = Math.max(0, s.yra - 80);
|
||||
s.nextYraTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120);
|
||||
}
|
||||
|
||||
if (s.vav >= 100) {
|
||||
s.phase = 'won';
|
||||
pushLog(s, { tick: s.tick, text: 'Väven sluter sig. Kvarnens väggar andas ut, en gång, och blir stilla. Skellefteälven fryser mitt i strömmen. Ni bär masken nu. Ni bar den redan innan ni satte på den.', kind: 'system' });
|
||||
} else if (s.faste <= 0) {
|
||||
s.phase = 'lost';
|
||||
s.lostReason = 'Ni tappar taget någonstans mellan sömn och vaka, och det finns inget att gripa efter på väg tillbaka. Kvarnen står tom vid gryningen. Fem par skor står prydligt i rad. Tårna pekar mot vattnet.';
|
||||
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
const MAX_OFFLINE_SECONDS = 24 * 3600;
|
||||
const MIN_OFFLINE_SECONDS = 60;
|
||||
const OFFLINE_GRACE_SECONDS = 5 * 60;
|
||||
const MAX_OFFLINE_FASTE_DROP = 35;
|
||||
|
||||
export function applyOfflineProgress(state: State, awayMs: number): State {
|
||||
if (state.phase !== 'playing') return state;
|
||||
const awaySec = Math.min(MAX_OFFLINE_SECONDS, awayMs / 1000);
|
||||
if (awaySec < MIN_OFFLINE_SECONDS) return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
const startingFaste = s.faste;
|
||||
|
||||
s.sken += s.skenRate * s.barare * awaySec;
|
||||
s.eko += EKO_BASE_RATE * s.ekoRate * Math.pow(s.barare, 1.2) * awaySec;
|
||||
s.brasch += s.braschRate * awaySec;
|
||||
if (s.vavUnlocked) s.vav = Math.min(100, s.vav + s.vavRate * awaySec);
|
||||
|
||||
const yraScale = 1 + (s.brasch / 200);
|
||||
const yraGain = s.yraRate * yraScale * awaySec;
|
||||
const yraReleased = s.yra + yraGain >= 100;
|
||||
s.yra = Math.min(100, s.yra + yraGain);
|
||||
|
||||
const drainSec = Math.max(0, awaySec - OFFLINE_GRACE_SECONDS);
|
||||
const barareDrain = s.barare > 4 ? FASTE_DRAIN_PER_BARARE * (s.barare - 4) : 0;
|
||||
const braschDrain = BRASCH_FASTE_DRAIN * (s.brasch / (s.brasch + 150));
|
||||
const vavDrain = s.vavUnlocked ? VAV_FASTE_DRAIN * (s.vav / 100) : 0;
|
||||
const rawDrop = (barareDrain + braschDrain + vavDrain) * drainSec;
|
||||
s.faste = Math.max(0, s.faste - Math.min(MAX_OFFLINE_FASTE_DROP, rawDrop));
|
||||
|
||||
const eventRolls = Math.min(3, Math.floor(awaySec / 7200));
|
||||
for (let i = 0; i < eventRolls; i++) {
|
||||
const ev = pickEvent(STANDARD_EVENTS.filter(e => !e.choices?.length), s);
|
||||
if (!ev) break;
|
||||
s.usedEventIds.add(ev.id);
|
||||
applyEffect(s, ev.effect);
|
||||
}
|
||||
if (yraReleased) {
|
||||
s.yra = Math.max(0, s.yra - 80);
|
||||
}
|
||||
|
||||
const totalDrop = startingFaste - s.faste;
|
||||
if (totalDrop > MAX_OFFLINE_FASTE_DROP) {
|
||||
s.faste = Math.max(0, startingFaste - MAX_OFFLINE_FASTE_DROP);
|
||||
}
|
||||
|
||||
const awayLabel =
|
||||
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
|
||||
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
|
||||
'länge — för länge för att räkna';
|
||||
const summary =
|
||||
awaySec < 3600 ? `Ni var borta i ${awayLabel}. Kvarnen väntade, otålig, tänderna av kugghjul.` :
|
||||
awaySec < 6 * 3600 ? `Ni var borta i ${awayLabel}. Sken samlades i mörkret utan er.` :
|
||||
awaySec < MAX_OFFLINE_SECONDS ? `Ni var borta i ${awayLabel}. Bräschen vidgades tyst medan ni sov med masken av.` :
|
||||
`Ni var borta ${awayLabel}. Kvarnen har fortsatt utan er. Det är oklart vem som höll i tråden.`;
|
||||
pushLog(s, { tick: s.tick, text: summary, kind: 'system' });
|
||||
|
||||
if (s.faste <= 0) {
|
||||
s.phase = 'lost';
|
||||
s.lostReason = 'Ni tappade taget någon gång medan ni var borta. Kvarnen står tom. Fem par skor står prydligt i rad. Tårna pekar mot vattnet.';
|
||||
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
|
||||
} else if (s.vav >= 100) {
|
||||
s.phase = 'won';
|
||||
pushLog(s, { tick: s.tick, text: 'Väven slöt sig utan er i rummet. Kvarnen andades ut, en gång, och blev stilla. Ni bär masken nu, oavsett.', kind: 'system' });
|
||||
}
|
||||
|
||||
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), flags: new Set(state.flags) };
|
||||
applyEffect(s, choice.effect);
|
||||
if (choice.flag) s.flags.add(choice.flag);
|
||||
pushLog(s, { tick: s.tick, text: `↳ ${choice.resultText}`, kind: s.pendingEvent!.kind });
|
||||
s.pendingEvent = null;
|
||||
|
||||
if (s.faste <= 0) {
|
||||
s.phase = 'lost';
|
||||
s.lostReason = 'Ni tappar taget. Kvarnen står tom vid gryningen. Fem par skor står prydligt i rad. Tårna pekar mot vattnet.';
|
||||
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
|
||||
} else if (s.vav >= 100) {
|
||||
s.phase = 'won';
|
||||
pushLog(s, { tick: s.tick, text: 'Väven sluter sig. Kvarnens väggar andas ut, en gång, och blir stilla. Ni bär masken nu. Ni bar den redan innan ni satte på den.', kind: 'system' });
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
interface LocationVisit {
|
||||
texts: string[];
|
||||
effect: Partial<{ sken: number; brasch: number; yra: number; vav: number; faste: number }>;
|
||||
requiresArea?: keyof State['areas'];
|
||||
lockedText: string;
|
||||
}
|
||||
|
||||
const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
kvarnen: {
|
||||
texts: [
|
||||
'Kugghjulen som inte längre är kopplade till någonting rör sig ändå, sakta, mot medurs. +sken',
|
||||
'Ni hittar en väv på golvet ingen av er påbörjat. Mönstret upprepar sig för långt ner för att följa med ögat. +sken',
|
||||
'Elementen är varma trots att elen stängdes av 2004. Ingen frågar varför längre. +sken',
|
||||
'Det luktar blött trä och något sött under det. Ingen av er säger det högt, men alla känner det. +sken',
|
||||
],
|
||||
effect: { sken: 15 },
|
||||
requiresArea: 'kvarnen',
|
||||
lockedText: 'Det gamla sågverket vid älven. Ni har fortfarande bara sett de yttre rummen. Något innanför väntar på att bli hittat, inte det motsatta.',
|
||||
},
|
||||
bonnstan: {
|
||||
texts: [
|
||||
'Kyrkstugorna står tätt, rad efter rad, och en av dem har ett fönster tänt som inte fanns i går. +brasch +vav',
|
||||
'Snön runt en särskild stuga smälter i ett perfekt, symmetriskt mönster. Ingen går dit. Ni går dit ändå. +brasch +vav',
|
||||
'Klockorna i kyrktornet slår tretton, en gång, och sedan aldrig igen. Ni är de enda som verkar ha hört det. +brasch +vav',
|
||||
'Dörren till en stuga som borde vara låst står på glänt. Innanför väntar mörker som inte reflekterar era ficklampor. +brasch +vav',
|
||||
],
|
||||
effect: { brasch: 8, vav: 2 },
|
||||
requiresArea: 'bonnstan',
|
||||
lockedText: 'Bonnstans kyrkstugor, tomma sedan sekler utom en helg om året. Det är inte den helgen nu. Ni är inte redo att gå in.',
|
||||
},
|
||||
sara: {
|
||||
texts: [
|
||||
'Tornet knakar i vinden som ett levande skelett som just vaknat. Det pekar mot något ingen karta visar. +brasch +vav',
|
||||
'Ett fönster högt uppe lyser fast byggnaden borde vara tom vid den här tiden. Ni räknar våningarna två gånger och får olika svar. +brasch +vav',
|
||||
'Trästommen andas, långsamt, en gång per minut, i takt med något under jorden. +brasch +vav',
|
||||
'Hissen stannar på ett plan som inte finns på skylten i entrén. Dörrarna öppnas. Ingen kliver ut. Ni kliver in. +brasch +vav',
|
||||
],
|
||||
effect: { brasch: 15, vav: 5 },
|
||||
requiresArea: 'sara',
|
||||
lockedText: 'Trätornet mitt i stan reser sig som en spik i himlen. Det ser er redan. Ni ser inte tillbaka än.',
|
||||
},
|
||||
alvkajen: {
|
||||
texts: [
|
||||
'Älven rinner uppström i en tunn strimma längs kajkanten, bara där, bara nu. +brasch',
|
||||
'Isen knakar i ett mönster som liknar text, om man vägrar sluta titta. +brasch',
|
||||
'En pråm glider förbi utan motorljud. Besättningen står helt stilla på däck och vänder ansiktena mot er. +brasch',
|
||||
'Måsarna över vattnet flyger i en cirkel som aldrig sluts, tills ni tittar bort. +brasch',
|
||||
],
|
||||
effect: { brasch: 5 },
|
||||
lockedText: 'Älvkajen. Vattnet är svart av kväll och kyla. Det verkar veta att ni stod här.',
|
||||
},
|
||||
nordana: {
|
||||
texts: [
|
||||
'Hjortarna i hägnet vänder sig samtidigt mot er, sedan mot tornet, sedan tillbaka mot er. +sken',
|
||||
'Museets fönster reflekterar en himmel som inte matchar den ovanför er. +sken',
|
||||
'Ett barns lekplats gungar i vinden — alla gungorna, i perfekt synk, ingen sitter i dem. +sken',
|
||||
'Parkbelysningen blinkar i morsekod ingen av er kan tyda, förutom en av er, som blir tyst efteråt. +sken',
|
||||
],
|
||||
effect: { sken: 8 },
|
||||
lockedText: 'Nordanå är stilla nu, parken tom. Djuren i hägnet är vakna. De väntar på rätt tidpunkt.',
|
||||
},
|
||||
landskyrkan: {
|
||||
texts: [
|
||||
'Gravstenarna från 1700-talet är kalla att röra vid oavsett väder. Namnen på dem stämmer inte med kyrkoboken. +sken',
|
||||
'Klockringningen kommer inifrån marken, inte från tornet. +sken',
|
||||
'En psalm sjungs av ingen, i ett tomt kapell, på ett språk som nästan är svenska. +sken',
|
||||
'Ett bänkrum längst bak har alltid varit reserverat. Ingen minns för vem. Det är fortfarande tomt. Det känns upptaget. +sken',
|
||||
],
|
||||
effect: { sken: 10 },
|
||||
lockedText: 'Landskyrkan, vitmålad och gammal. Bakom den, en kyrkogård som är större under jord än ovan.',
|
||||
},
|
||||
torget: {
|
||||
texts: [
|
||||
'Ni vet inte varför ni kom hit. Ni vet inte hur länge ni stått här. +yra',
|
||||
'Julgranen mitt på torget står kvar i juli, grön som om det vore december. Ingen kommunanställd vill kommentera. +yra',
|
||||
'Alla klockor på torgets fasader visar olika tid. Ingen av dem är fel. +yra',
|
||||
'Ett gatumusikkorband spelar samma stycke som förra gången ni var här. De säger att de aldrig spelat det förut. +yra',
|
||||
],
|
||||
effect: { yra: 8 },
|
||||
lockedText: 'Nya torget är fullt av folk som inte vet vad de gör här heller. Ni passar in.',
|
||||
},
|
||||
skogen: {
|
||||
texts: [
|
||||
'Träden står i en cirkel som inte finns på någon skogsbruksplan. Barken är vänd inåt, mot mitten, mot er. +yra',
|
||||
'Ett djurspår i snön går på två ben, sedan fyra, sedan två igen, rakt mot kvarnen. +yra',
|
||||
'Tystnaden är total. Inga fåglar. Inte ens vinden. Sedan, en enda not, för långt bort för att lokalisera. +yra',
|
||||
'Mossan lyser svagt under fötterna, i ett mönster som följer er, inte tvärtom. +yra',
|
||||
],
|
||||
effect: { yra: 10 },
|
||||
lockedText: 'Granskogen norr om älven är tät och gammal. Något i den räknar era steg.',
|
||||
},
|
||||
campus: {
|
||||
texts: [
|
||||
'Biblioteket har en avdelning för sällan efterfrågad litteratur. Er kod fungerar där ingen minns att ni fått en. +sken +brasch',
|
||||
'En föreläsningssal står tom, whiteboarden full av diagram ingen lärare ritat. Sista raden är inte matematik — det är ett ansikte. +sken +brasch',
|
||||
'Studenterna i korridoren pratar lägre än vanligt när ni går förbi. En av dem tittar upp för länge, och ler för mycket. +sken +brasch',
|
||||
'Ett skåp har stått olåst i terminer utan att någon rört det. Innanför ligger en mask, format efter ett ansikte ingen av er känner igen. Det liknar ändå någon. +sken +brasch',
|
||||
],
|
||||
effect: { sken: 12, brasch: 6 },
|
||||
lockedText: 'Campus Skellefteå, glas och trä och tystnad mellan föreläsningarna. Något i arkiven väntar på att bli uppslaget.',
|
||||
},
|
||||
};
|
||||
|
||||
export const VISITABLE_LOCATION_IDS = Object.keys(LOCATION_VISITS);
|
||||
|
||||
const VISIT_COOLDOWN_TICKS = 900; // 30s at 30fps
|
||||
|
||||
export function visitLocation(state: State, id: string, cooldowns: Map<string, number>): { state: State; cooldowns: Map<string, number> } {
|
||||
const visit = LOCATION_VISITS[id];
|
||||
if (!visit) return { state, cooldowns };
|
||||
|
||||
const now = state.tick;
|
||||
if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns };
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
|
||||
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
|
||||
const text = areaLocked ? visit.lockedText : visit.texts[Math.floor(Math.random() * visit.texts.length)];
|
||||
|
||||
if (!areaLocked) {
|
||||
if (visit.effect.sken) s.sken += visit.effect.sken;
|
||||
if (visit.effect.brasch) s.brasch += visit.effect.brasch;
|
||||
if (visit.effect.yra) s.yra = Math.min(100, s.yra + visit.effect.yra);
|
||||
if (visit.effect.vav && s.vavUnlocked) s.vav = Math.min(100, s.vav + visit.effect.vav);
|
||||
if (visit.effect.faste) s.faste = Math.max(0, Math.min(100, s.faste + visit.effect.faste));
|
||||
}
|
||||
|
||||
pushLog(s, { tick: now, text: `⬡ ${text}`, kind: 'system' });
|
||||
|
||||
const newCooldowns = new Map(cooldowns);
|
||||
newCooldowns.set(id, now + VISIT_COOLDOWN_TICKS);
|
||||
|
||||
return { state: s, cooldowns: newCooldowns };
|
||||
}
|
||||
|
||||
export function saveState(state: State): void {
|
||||
try {
|
||||
const serial = {
|
||||
...state,
|
||||
upgrades: [...state.upgrades],
|
||||
usedEventIds: [...state.usedEventIds],
|
||||
flags: [...state.flags],
|
||||
log: state.log.slice(0, 20),
|
||||
};
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(serial));
|
||||
} catch (_) { /* storage full or unavailable */ }
|
||||
}
|
||||
|
||||
export function loadState(): State | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (!raw) return null;
|
||||
const d = JSON.parse(raw);
|
||||
return {
|
||||
...d,
|
||||
upgrades: new Set<string>(d.upgrades ?? []),
|
||||
usedEventIds: new Set<string>(d.usedEventIds ?? []),
|
||||
flags: new Set<string>(d.flags ?? []),
|
||||
logSeq: d.logSeq ?? 0,
|
||||
lastRealTime: d.lastRealTime ?? Date.now(),
|
||||
} as State;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSave(): void {
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
}
|
||||
|
||||
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;
|
||||
const cost = upgrade.cost(state);
|
||||
if (cost.sken && state.sken < cost.sken) return state;
|
||||
if (cost.brasch && state.brasch < cost.brasch) return state;
|
||||
if (cost.eko && state.eko < cost.eko) return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
if (cost.sken) s.sken -= cost.sken;
|
||||
if (cost.brasch) s.brasch -= cost.brasch;
|
||||
if (cost.eko) s.eko -= cost.eko;
|
||||
upgrade.effect(s);
|
||||
if (upgrade.once) s.upgrades.add(id);
|
||||
pushLog(s, { tick: s.tick, text: `↳ ${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
|
||||
return s;
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import type { State } from './engine.ts';
|
||||
|
||||
export interface EventChoice {
|
||||
text: string;
|
||||
resultText: string;
|
||||
effect: Partial<{ sken: number; faste: number; yra: number; brasch: number; barare: number; vav: number }>;
|
||||
flag?: string;
|
||||
}
|
||||
|
||||
export interface GameEvent {
|
||||
id: string;
|
||||
text: string;
|
||||
weight: number;
|
||||
effect: Partial<{ sken: number; faste: number; yra: number; brasch: number; barare: number; vav: number }>;
|
||||
choices?: EventChoice[];
|
||||
requires?: {
|
||||
minBarare?: number;
|
||||
minBrasch?: number;
|
||||
minVav?: number;
|
||||
unlockedArea?: keyof State['areas'];
|
||||
flag?: string;
|
||||
completedEventId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const STANDARD_EVENTS: GameEvent[] = [
|
||||
{
|
||||
id: 'first_dream',
|
||||
text: 'Ni drömmer alla samma dröm samma natt: en stad byggd av något som inte är sten, under ett hav som inte är vatten. Ingen av er nämner det förrän frukosten, och då säger ni det samtidigt.',
|
||||
weight: 10,
|
||||
effect: { brasch: 5, yra: 2 },
|
||||
},
|
||||
{
|
||||
id: 'mirror_lag',
|
||||
text: 'Spegeln i kvarnens tvättrum släpar en halv sekund efter rörelserna nu. Ingen har bytt ut den. Ingen vill stå kvar länge nog för att mäta hur mycket det ökar.',
|
||||
weight: 9,
|
||||
effect: { brasch: 4 },
|
||||
},
|
||||
{
|
||||
id: 'discord_erik',
|
||||
text: 'En kille som heter Erik dyker upp av misstag — lades till i fel Discord-server, trodde ni höll på med ett brädspel om vikingar. Feedbacken han gav var konstig nog att han fick stanna.',
|
||||
weight: 6,
|
||||
effect: { barare: 1, sken: 10 },
|
||||
},
|
||||
{
|
||||
id: 'sawmill_ledger',
|
||||
text: 'En gammal huvudbok från sågverket dyker upp bakom en lös planka. Sista posten är daterad om tre veckor, med en summa som stämmer exakt med det ni har i kassan just nu.',
|
||||
weight: 8,
|
||||
effect: { sken: 20, yra: 4 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Riv ut sidan',
|
||||
resultText: 'Ni river ut sidan och bränner den. Askan bildar samma summa i siffror på golvet innan den slocknar.',
|
||||
effect: { faste: 5, yra: 3 },
|
||||
},
|
||||
{
|
||||
text: 'Lägg tillbaka boken där ni hittade den',
|
||||
resultText: 'Ni lägger tillbaka den. Nästa gång ni tittar är sidan borta, som om den aldrig skrivits — bara summan finns kvar, i era huvuden, som ett mynt ni inte kan sluta räkna.',
|
||||
effect: { brasch: 6, sken: 15 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lyckseleman_letter',
|
||||
text: 'Ett brev kommer från en avsändare i Lycksele som ingen av er känner, adresserat med era riktiga namn, alla nio, trots att ni bara är fem den här veckan. Det innehåller en enda mening ni inte pratar om.',
|
||||
weight: 5,
|
||||
effect: { brasch: 8, faste: -5 },
|
||||
requires: { minBarare: 3 },
|
||||
},
|
||||
{
|
||||
id: 'ren_pa_isen',
|
||||
text: 'En ren står ute på älvisen där isen borde vara för tunn. Den rör sig inte när ni ropar. Den rör sig inte alls, förrän ni tittar bort, och då är den plötsligt mycket närmare.',
|
||||
weight: 8,
|
||||
effect: { yra: 6 },
|
||||
},
|
||||
{
|
||||
id: 'sold_house',
|
||||
text: 'Ett hus på er gata säljs plötsligt, billigt, av en ägare ingen mindes bodde där. Köparen flyttar aldrig in. Lamporna tänds ändå, varje kväll, klockan samma tid.',
|
||||
weight: 7,
|
||||
effect: { brasch: 6, sken: 10 },
|
||||
},
|
||||
{
|
||||
id: 'the_photograph',
|
||||
text: 'Ett fotografi från trettiotalet visar sågverksarbetare uppställda för porträtt. En av er står längst bak, i overall, med samma ansikte som idag. Ingen i bilden ler.',
|
||||
weight: 4,
|
||||
effect: { brasch: 10, yra: 5 },
|
||||
requires: { minBrasch: 40 },
|
||||
},
|
||||
{
|
||||
id: 'text_language',
|
||||
text: 'Ni börjar utan att märka det tänka i Thörnepherian istället för svenska, bara för en sekund åt gången. Det är effektivare. Ord för saker ni tidigare behövde meningar för.',
|
||||
weight: 5,
|
||||
effect: { sken: 25, faste: -6 },
|
||||
requires: { flag: 'has_language' },
|
||||
},
|
||||
{
|
||||
id: 'kvarnen_grows',
|
||||
text: 'Källaren under kvarnen har fler rum än ritningarna medger. Ni hittade tre nya i förra veckan. Ni har slutat rita nya kartor — det känns oartigt att kartlägga något som fortfarande bestämmer sig.',
|
||||
weight: 6,
|
||||
effect: { brasch: 7 },
|
||||
requires: { unlockedArea: 'kvarnen' },
|
||||
},
|
||||
{
|
||||
id: 'bonnstan_visitor',
|
||||
text: 'En turist i Bonnstan frågar er om vägen till en kyrkstuga som inte finns på kartan. Ni visar honom ändå. Han tackar på Thörnepherian utan att veta det.',
|
||||
weight: 5,
|
||||
effect: { yra: 5, sken: 8 },
|
||||
requires: { unlockedArea: 'bonnstan' },
|
||||
},
|
||||
{
|
||||
id: 'sara_hum',
|
||||
text: 'Trätornet surrar på en frekvens ni känner i tänderna snarare än öronen, hela vägen hem, i timmar efteråt.',
|
||||
weight: 5,
|
||||
effect: { brasch: 10, faste: -4 },
|
||||
requires: { unlockedArea: 'sara' },
|
||||
},
|
||||
{
|
||||
id: 'discord_erik_missing',
|
||||
text: 'Erik slutar svara i gruppchatten. Hans lägenhet är tom men olåst, kaffekoppen fortfarande varm på bordet. Hans mask, den han fick men aldrig sagt ja till att bära, ligger prydligt lagd på hans kudde.',
|
||||
weight: 3,
|
||||
effect: { brasch: 12, faste: -8 },
|
||||
requires: { completedEventId: 'discord_erik', minBrasch: 60 },
|
||||
},
|
||||
{
|
||||
id: 'researcher_joins',
|
||||
text: 'En doktorand vid Campus råkade översätta tre rader Thörnepherian för ett annat projekt. Hon söker medlemskap nu och säger att hon bara vill se hur det slutar. Ni tror henne inte. Ni släpper in henne ändå.',
|
||||
weight: 4,
|
||||
effect: { barare: 1, sken: 15 },
|
||||
requires: { minBarare: 3, minBrasch: 30 },
|
||||
},
|
||||
{
|
||||
id: 'the_dagger_dream',
|
||||
text: 'Samma dröm igen, men den här gången håller ni alla i en kniv format av något som inte är metall, och ni vet exakt vad den är till för utan att någon förklarat det.',
|
||||
weight: 4,
|
||||
effect: { brasch: 14, yra: 8 },
|
||||
requires: { minBrasch: 80 },
|
||||
},
|
||||
{
|
||||
id: 'the_only_winning_move',
|
||||
text: 'Ni inser mitt i natten att det bara finns ett drag kvar att göra, och att ni redan bestämt er för att göra det, för länge sedan, innan ni ens visste om beslutet.',
|
||||
weight: 3,
|
||||
effect: { vav: 4, faste: -6 },
|
||||
requires: { minVav: 20 },
|
||||
},
|
||||
{
|
||||
id: 'terminal_bliss',
|
||||
text: 'En av er ler i sömnen, hela natten, ett leende som inte lossnar när hen vaknar. Det är inte obehagligt att se på. Det är bara svårt att sluta göra det.',
|
||||
weight: 5,
|
||||
effect: { sken: 18, faste: -5 },
|
||||
requires: { minBrasch: 50 },
|
||||
},
|
||||
];
|
||||
|
||||
export const YRA_EVENTS: GameEvent[] = [
|
||||
{
|
||||
id: 'yra_masks_swap',
|
||||
text: 'Ni vaknar med fel mask på, någon annans ansikte format efter ert. Det tar en stund att komma ihåg vems det egentligen var.',
|
||||
weight: 8,
|
||||
effect: { yra: -20, brasch: 4 },
|
||||
},
|
||||
{
|
||||
id: 'yra_ice_song',
|
||||
text: 'Sången under isen blir så tydlig att ni alla nynnar med utan att vilja, i exakt samma tonart, i era separata hem, samtidigt.',
|
||||
weight: 8,
|
||||
effect: { sken: 15 },
|
||||
},
|
||||
{
|
||||
id: 'yra_forgot_day',
|
||||
text: 'Ingen av er kan komma överens om vilken veckodag det är. Kalendrarna säger olika saker i olika rum i samma hus.',
|
||||
weight: 7,
|
||||
effect: { faste: -5 },
|
||||
},
|
||||
{
|
||||
id: 'yra_reversed_snow',
|
||||
text: 'Snön utanför kvarnen faller uppåt i femton sekunder. Ni filmar det inte. Det känns viktigt att inte filma det.',
|
||||
weight: 6,
|
||||
effect: { brasch: 8 },
|
||||
},
|
||||
{
|
||||
id: 'yra_extra_chair',
|
||||
text: 'Det finns en stol för mycket i mötesrummet igen. Ingen sätter sig i den. Den är ändå varm när ni rör vid den på väg ut.',
|
||||
weight: 6,
|
||||
effect: { yra: -15, brasch: 3 },
|
||||
},
|
||||
{
|
||||
id: 'yra_choice_ritual',
|
||||
text: 'Väven ber om ett val, plötsligt och tydligt, mitt i ett vanligt möte: ge upp en natts sömn, permanent, för alla er — eller ge upp en av era namn.',
|
||||
weight: 4,
|
||||
effect: {},
|
||||
choices: [
|
||||
{
|
||||
text: 'Ge upp sömnen',
|
||||
resultText: 'Ingen av er sover ordentligt igen. Ni är alltid lite mer vakna nu, lite mer närvarande i rummen ni går igenom, oavsett tid på dygnet.',
|
||||
effect: { vav: 3, faste: -10 },
|
||||
},
|
||||
{
|
||||
text: 'Ge upp ett namn',
|
||||
resultText: 'En av er svarar inte längre på sitt eget namn. Ni kallar hen något annat nu, ett ord ur Thörnepherian som betyder ungefär "den som gick före".',
|
||||
effect: { vav: 3, brasch: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'yra_mirror_others',
|
||||
text: 'Alla speglar i kvarnen visar samma rum, men tomt, i tjugo minuter. Sen visar de er igen, som om ingenting hänt.',
|
||||
weight: 6,
|
||||
effect: { brasch: 6, sken: 10 },
|
||||
},
|
||||
{
|
||||
id: 'yra_static_voice',
|
||||
text: 'Radion i lastbilen ute på gården sätter på sig själv, mitt i natten, och spelar en röst som räknar baklänges på ett språk som blir mer och mer begripligt för varje siffra.',
|
||||
weight: 5,
|
||||
effect: { yra: -25, brasch: 5 },
|
||||
},
|
||||
{
|
||||
id: 'yra_feathermeal',
|
||||
text: 'Fåglarna vid älven faller döda ur luften, en efter en, i en rak linje mot kvarnen. Ingen av er nämner ordet "tecken" högt, men alla tänker det.',
|
||||
weight: 5,
|
||||
effect: { brasch: 9, faste: -4 },
|
||||
},
|
||||
{
|
||||
id: 'yra_cascade',
|
||||
text: 'Alla era klockor — telefoner, väckarklockor, kyrktornet på håll — slår samma minut fel, i en våg som rör sig genom stan som ringar på vatten.',
|
||||
weight: 6,
|
||||
effect: { yra: -20, vav: 1 },
|
||||
},
|
||||
];
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import type { State } from './engine.ts';
|
||||
|
||||
export interface LoreEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
unlock: (s: State) => boolean;
|
||||
}
|
||||
|
||||
export const LORE: LoreEntry[] = [
|
||||
{
|
||||
id: 'sagverket',
|
||||
title: 'Sågverket, 1897–1998',
|
||||
body: 'Byggt av tre bröder Ahlqvist som sålde det vidare inom femton år, alla tre, till tre olika köpare, utan att någonsin förklara varför. Verksamheten bytte ägare sju gånger på hundra år. Ingen ägare stannade mer än arton år. Den siste lämnade nycklarna på disken i macken i Ursviken och for söderut samma kväll. Fastighetsregistret listar den nuvarande ägaren som "okänd".',
|
||||
unlock: () => true,
|
||||
},
|
||||
{
|
||||
id: 'fore_sagverket',
|
||||
title: 'Innan sågverket',
|
||||
body: 'En anteckning från en resande präst 1761 beskriver marken vid forsen som en plats renarna alltid gick runt, aldrig över, även när den kortare vägen låg rakt fram. Han skriver att de lokala kallade stället för "det som lyssnar" och lät det vara. Han rekommenderar att kyrkan gör detsamma. Rekommendationen arkiverades och glömdes i 136 år, tills sågverket byggdes rakt ovanpå.',
|
||||
unlock: s => s.brasch >= 10,
|
||||
},
|
||||
{
|
||||
id: 'forsta_bararna',
|
||||
title: 'De första bärarna',
|
||||
body: 'En kommun på elva personer flyttade in i den då redan tomma kvarnen 1974 och kallade sig aldrig något särskilt. De höll på i tre år. Lokaltidningen skrev en enda gång om dem, ett litet reportage om "alternativt boende", med ett gruppfoto där elva står uppställda men bara tio kastar skugga. 1977 var byggnaden tom igen. Ingen anmälan gjordes. Ingen efterlyste dem.',
|
||||
unlock: s => s.brasch >= 25,
|
||||
},
|
||||
{
|
||||
id: 'thornepherian_grammatik',
|
||||
title: 'Om Thörnepherian',
|
||||
body: 'Språket har inget förstapersonspronomen i singular — bara ett ord som betyder ungefär "den som bär det som talar nu", och som byts ut beroende på sammanhang, aldrig av talaren själv. Lingvister som studerat fragment av det (det finns inga kompletta grammatikor, bara rekonstruktioner) är oense om det är ett språk för att kommunicera eller ett för att bytas ut. Ni har slutat fråga vilket.',
|
||||
unlock: s => s.upgrades.has('the_language'),
|
||||
},
|
||||
{
|
||||
id: 'vad_ar_thon',
|
||||
title: 'Vad är Thön?',
|
||||
body: 'Ingen text ni hittat definierar det. De närmaste beskrivningarna kallar det inte en plats utan ett tryck — ett annat sätt att vara sant som ligger an mot vårt, hela tiden, och som bara märks där väven mellan dem är tunn. Vissa källor talar om det som sovande. Andra som om det aldrig sovit, bara väntat på att bli hörsammat. Ingen av källorna är överens, och alla är skrivna av människor som slutade skriva strax efter.',
|
||||
unlock: s => s.brasch >= 50,
|
||||
},
|
||||
{
|
||||
id: 'masken_syfte',
|
||||
title: 'Maskens syfte',
|
||||
body: 'Masken är inte en förklädnad. Den är ett medgivande. Thön kan inte skilja er från det som redan bär ert ansikte på andra sidan — det har alltid kunnat bära det, lika bra som ni själva. Att skära en mask och sätta på den är att göra det officiellt: att sluta låtsas att ansiktet någonsin bara var ert.',
|
||||
unlock: s => s.upgrades.has('the_mask'),
|
||||
},
|
||||
{
|
||||
id: 'vaven_metafysik',
|
||||
title: 'Väven som sutur',
|
||||
body: 'Väven är ingen port. Ni har trott det länge, men inget i Thörnepherian stödjer det. Det närmaste ordet betyder "det som syr ihop utan att läka" — en sutur dragen över ett sår som aldrig slutar vara öppet, bara osynligt. När väven sluts syns ingenting. Det är inte detsamma som att ingenting hände.',
|
||||
unlock: s => s.vav >= 20,
|
||||
},
|
||||
{
|
||||
id: 'brasch_grammatik',
|
||||
title: 'När bräschen vidgas',
|
||||
body: 'Vid hög bräsch slutar vissa saker följa sin egen grammatik: dörrar som alltid gått att öppna slutar göra det i en riktning, sedan den andra; klockor som går exakt fel med exakt samma marginal, om och om igen, som om felet självt vore avsiktligt. Det är inte kaos. Det är regler ni ännu inte lärt er, tillämpade konsekvent av något som redan kan dem utantill.',
|
||||
unlock: s => s.brasch >= 80,
|
||||
},
|
||||
{
|
||||
id: 'koren_under_isen',
|
||||
title: 'Kören under isen',
|
||||
body: 'En dagbok från 1902, hittad inklämd bakom en vägg i kvarnens källare, beskriver "sång under vattnet, hörd tydligast i januari, alltid i moll, alltid i en sjätte del fler röster än det borde finnas fiskare i socknen." Dagboksskrivaren slutar anteckna i mars samma år. De sista raderna är på ett språk han inte tidigare skrivit på. Det är inte svenska. Det är inte helt Thörnepherian heller — snarare något mitt emellan, som om han höll på att lära sig det medan han skrev.',
|
||||
unlock: s => s.upgrades.has('sunken_choir'),
|
||||
},
|
||||
{
|
||||
id: 'fem_stolar',
|
||||
title: 'Fem stolar',
|
||||
body: 'Hur många ni än är räknar rummet fel — fem stolar dras alltid fram, aldrig fyra, aldrig sex, oavsett hur många av er som faktiskt kommer till mötet. Ingen minns vem som bestämde antalet. Den femte stolen är alltid vänd mot älven, även när ni vänder tillbaka den. Ni har slutat vända tillbaka den.',
|
||||
unlock: s => s.barare >= 5 || s.vav >= 40,
|
||||
},
|
||||
{
|
||||
id: 'de_som_slutade',
|
||||
title: 'De som slutade komma',
|
||||
body: 'Kvarnens medlemslista, förd informellt i en anteckningsbok i köket, har fler namn strukna än kvarstående. Ingen av er för strecken. De dyker bara upp, ett i taget, alltid samma sorts blyertsstreck, alltid dragna rakt igenom, aldrig runt. Ingen har någonsin sett vem som stryker ett namn. Alla vet exakt vilket namn som stryks härnäst.',
|
||||
unlock: s => s.faste <= 60,
|
||||
},
|
||||
{
|
||||
id: 'sista_sidan',
|
||||
title: 'Den sista sidan',
|
||||
body: 'Thörnepherian-texten ni till slut fick fullständig hade en sista sida ingen av er skrev. Handstilen skiftar mellan varje rad, som om nio olika personer turades om att hålla i pennan, en rad var, i ordning, om och om igen. Ni har räknat raderna. De är exakt så många som ni någonsin kommer vara.',
|
||||
unlock: s => s.upgrades.has('grand_weave_prep'),
|
||||
},
|
||||
];
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import './style.css';
|
||||
import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave, applyOfflineProgress, VISITABLE_LOCATION_IDS } from './engine.ts';
|
||||
import { initUI, renderState, setPausedUI, showLocationArt } from './ui.ts';
|
||||
import { initMap, updateMap, LOCATION_INFO } from './map.ts';
|
||||
|
||||
let state = loadState() ?? createState();
|
||||
|
||||
if (state.phase === 'playing') {
|
||||
state = applyOfflineProgress(state, Date.now() - (state.lastRealTime ?? Date.now()));
|
||||
}
|
||||
|
||||
let visitCooldowns = new Map<string, number>();
|
||||
let paused = false;
|
||||
let lastTime = 0;
|
||||
const TARGET_FPS = 30;
|
||||
const FRAME_MS = 1000 / TARGET_FPS;
|
||||
const SAVE_INTERVAL_MS = 10_000;
|
||||
let lastSaveTime = 0;
|
||||
|
||||
const WHISPER_INTERVAL_TICKS = 30 * 45; // 45s at 30fps
|
||||
const WHISPER_RETRY_TICKS = 30 * 5;
|
||||
let nextWhisperTick = 0;
|
||||
|
||||
const BASE_TITLE = document.title;
|
||||
const HAUNT_MESSAGES = ['Det väntar…', 'Väck det…', 'Älven kallar…', 'Kom tillbaka…', '☿ Kvarnen minns ☿'];
|
||||
let hauntTimer: number | undefined;
|
||||
let hauntIndex = 0;
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
if (state.phase !== 'playing') return;
|
||||
hauntIndex = 0;
|
||||
hauntTimer = window.setInterval(() => {
|
||||
document.title = HAUNT_MESSAGES[hauntIndex % HAUNT_MESSAGES.length]!;
|
||||
hauntIndex += 1;
|
||||
}, 2500);
|
||||
} else {
|
||||
if (hauntTimer !== undefined) { window.clearInterval(hauntTimer); hauntTimer = undefined; }
|
||||
document.title = BASE_TITLE;
|
||||
if (state.phase === 'playing') {
|
||||
state = applyOfflineProgress(state, Date.now() - (state.lastRealTime ?? Date.now()));
|
||||
state = { ...state, lastRealTime: Date.now() };
|
||||
renderState(state);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function start(): void {
|
||||
const app = document.getElementById('app')!;
|
||||
|
||||
initUI(app,
|
||||
(id) => { state = buyUpgrade(state, id); renderState(state); saveState(state); },
|
||||
() => {
|
||||
clearSave();
|
||||
state = createState();
|
||||
visitCooldowns = new Map();
|
||||
document.getElementById('overlay')!.classList.remove('visible');
|
||||
renderState(state);
|
||||
},
|
||||
(index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); },
|
||||
() => { state = startGame(state); renderState(state); saveState(state); },
|
||||
() => { state = abandonGame(state); renderState(state); saveState(state); },
|
||||
() => { paused = !paused; setPausedUI(paused); }
|
||||
);
|
||||
|
||||
initMap('map', (locationId) => {
|
||||
const result = visitLocation(state, locationId, visitCooldowns);
|
||||
state = result.state;
|
||||
visitCooldowns = result.cooldowns;
|
||||
renderState(state);
|
||||
|
||||
const info = LOCATION_INFO[locationId];
|
||||
if (info) showLocationArt(locationId, info.label, info.blurb);
|
||||
});
|
||||
renderState(state);
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function loop(now: number): void {
|
||||
if (now - lastTime >= FRAME_MS) {
|
||||
lastTime = now;
|
||||
if (state.phase === 'playing' && !paused) {
|
||||
state = tick(state);
|
||||
|
||||
if (state.upgrades.has('the_mask_leads') && state.tick >= nextWhisperTick) {
|
||||
const tickNow = state.tick;
|
||||
const available = VISITABLE_LOCATION_IDS.filter(id => (visitCooldowns.get(id) ?? 0) <= tickNow);
|
||||
if (available.length) {
|
||||
const id = available[Math.floor(Math.random() * available.length)]!;
|
||||
const result = visitLocation(state, id, visitCooldowns);
|
||||
state = result.state;
|
||||
visitCooldowns = result.cooldowns;
|
||||
nextWhisperTick = state.tick + WHISPER_INTERVAL_TICKS;
|
||||
} else {
|
||||
nextWhisperTick = state.tick + WHISPER_RETRY_TICKS;
|
||||
}
|
||||
}
|
||||
|
||||
renderState(state);
|
||||
updateMap(state.brasch, state.areas);
|
||||
}
|
||||
if (now - lastSaveTime >= SAVE_INTERVAL_MS) {
|
||||
lastSaveTime = now;
|
||||
state = { ...state, lastRealTime: Date.now() };
|
||||
saveState(state);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
start();
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
// 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;
|
||||
braschLevel: number; // 0-3
|
||||
}
|
||||
|
||||
interface LocationInfo {
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
const LOCATIONS: Record<string, LocationInfo> = {
|
||||
kvarnen: {
|
||||
lat: 64.7523, lng: 20.9450, label: 'Den gamla kvarnen',
|
||||
blurb: 'Ett nedlagt sågverk vid Skellefteälven som blev något annat. Ingen av er minns exakt när. Elbolaget vet fortfarande inte om det.',
|
||||
},
|
||||
bonnstan: {
|
||||
lat: 64.7554, lng: 20.9556, label: 'Bonnstan',
|
||||
blurb: 'Kyrkstugor, tätt packade, tomma sedan sekler utom en helg om året. Krita på en dörr som inte suddas, hur mycket det än snöar.',
|
||||
},
|
||||
sara: {
|
||||
lat: 64.7500, lng: 20.9540, label: 'Sara kulturhus',
|
||||
blurb: 'Arton våningar trä, format som en vridande ryggrad. Arkitekten sa att han drömde ritningen. Det visade sig inte vara en metafor.',
|
||||
},
|
||||
alvkajen: {
|
||||
lat: 64.7515, lng: 20.9480, label: 'Älvkajen',
|
||||
blurb: 'Skellefteälvens kaj. Vattnet under isen är stillare än det borde vara, oftare än det borde vara.',
|
||||
},
|
||||
nordana: {
|
||||
lat: 64.7495, lng: 20.9600, label: 'Nordanå',
|
||||
blurb: 'Park och friluftsmuseum med hjorthägn och stilla gungor som rör sig utan vind, alltid åt samma håll.',
|
||||
},
|
||||
landskyrkan: {
|
||||
lat: 64.7580, lng: 20.9610, label: 'Landskyrkan',
|
||||
blurb: 'Vitmålad kyrka från 1500-talet. Gravstenarna är kalla att röra vid oavsett årstid, och namnen på dem stämmer inte alltid med kyrkoboken.',
|
||||
},
|
||||
torget: {
|
||||
lat: 64.7507, lng: 20.9524, label: 'Nya torget',
|
||||
blurb: 'Stadens torg, klockor på fasaderna runtom som sällan visar exakt samma tid.',
|
||||
},
|
||||
skogen: {
|
||||
lat: 64.7610, lng: 20.9350, label: 'Granskogen',
|
||||
blurb: 'Tät gammal skog norr om älven. Något i den räknar era steg, även när ni går ensamma.',
|
||||
},
|
||||
campus: {
|
||||
lat: 64.7565, lng: 20.9505, label: 'Campus Skellefteå',
|
||||
blurb: 'Glas och trä och tystnad mellan föreläsningarna. En avdelning för sällan efterfrågad litteratur, där er kod alltid fungerar.',
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCATION_INFO: Record<string, { label: string; blurb: string }> =
|
||||
Object.fromEntries(Object.entries(LOCATIONS).map(([id, l]) => [id, { label: l.label, blurb: l.blurb }]));
|
||||
|
||||
const THUMB = (id: string): string => `/img/locations/${id}.png`;
|
||||
|
||||
type LeafletMap = ReturnType<typeof L.map>;
|
||||
type LeafletMarker = ReturnType<typeof L.circleMarker>;
|
||||
|
||||
let map: LeafletMap | null = null;
|
||||
const markers = new Map<string, LeafletMarker>();
|
||||
|
||||
function braschColor(level: number): string {
|
||||
return ['#1a1a2e', '#4a1a4a', '#8b0a8b', '#cc00cc'][Math.min(level, 3)];
|
||||
}
|
||||
|
||||
function braschRadius(level: number): number {
|
||||
return [6, 10, 16, 24][Math.min(level, 3)];
|
||||
}
|
||||
|
||||
export function initMap(containerId: string, onClick: (id: string) => void): void {
|
||||
if (!L) return;
|
||||
map = L.map(containerId, {
|
||||
center: [64.7523, 20.9520],
|
||||
zoom: 13,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
for (const [id, loc] of Object.entries(LOCATIONS)) {
|
||||
const m = L.circleMarker([loc.lat, loc.lng], {
|
||||
radius: 6,
|
||||
color: '#1a1a2e',
|
||||
fillColor: '#1a1a2e',
|
||||
fillOpacity: 0.3,
|
||||
weight: 1,
|
||||
}).addTo(map).bindTooltip(
|
||||
`<div class="map-tooltip-inner">
|
||||
<img class="map-tooltip-thumb" src="${THUMB(id)}" alt=""
|
||||
onerror="this.remove()">
|
||||
<span>${loc.label}</span>
|
||||
</div>`,
|
||||
{ className: 'map-tooltip', permanent: false },
|
||||
);
|
||||
m.on('click', () => onClick(id));
|
||||
markers.set(id, m);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateMap(brasch: number, areas: { kvarnen: boolean; bonnstan: boolean; sara: boolean }): void {
|
||||
if (!map) return;
|
||||
|
||||
const levels: Record<string, number> = {
|
||||
kvarnen: areas.kvarnen ? Math.min(3, Math.floor(brasch / 40) + 1) : Math.min(1, Math.floor(brasch / 60)),
|
||||
bonnstan: areas.bonnstan ? Math.min(3, Math.floor(brasch / 35) + 1) : 0,
|
||||
sara: areas.sara ? 3 : Math.min(1, Math.floor(brasch / 80)),
|
||||
alvkajen: Math.min(2, Math.floor(brasch / 50)),
|
||||
nordana: Math.min(2, Math.floor(brasch / 45)),
|
||||
landskyrkan: Math.min(1, Math.floor(brasch / 70)),
|
||||
torget: Math.min(2, Math.floor(brasch / 55)),
|
||||
skogen: Math.min(2, Math.floor(brasch / 48)),
|
||||
campus: Math.min(2, Math.floor(brasch / 65)),
|
||||
};
|
||||
|
||||
for (const [id, marker] of markers) {
|
||||
const level = levels[id] ?? 0;
|
||||
marker.setStyle({
|
||||
radius: braschRadius(level),
|
||||
color: braschColor(level),
|
||||
fillColor: braschColor(level),
|
||||
fillOpacity: 0.15 + level * 0.2,
|
||||
weight: level > 0 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
+680
@@ -0,0 +1,680 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #0d0d1a;
|
||||
--panel: #12121f;
|
||||
--border: #2a2a42;
|
||||
--text: #e2e2f0;
|
||||
--dim: #8888aa;
|
||||
--accent: #aa66dd; /* eldritch purple */
|
||||
--dread: #ee55ee; /* dread pink */
|
||||
--kaos: #ff8833; /* chaos orange */
|
||||
--safe: #44bb66; /* fäste green */
|
||||
--danger: #ee4444; /* investigator red */
|
||||
--vav: #66aaff; /* väv 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;
|
||||
filter: hue-rotate(calc(var(--dread-intensity, 0) * -35deg)) saturate(calc(1 + var(--dread-intensity, 0) * 0.7));
|
||||
transition: filter 2s ease;
|
||||
}
|
||||
|
||||
#app.kaos-glitch { animation: kaos-glitch-shake 0.4s steps(2, end) 2; }
|
||||
|
||||
@keyframes kaos-glitch-shake {
|
||||
0% { transform: translate(0, 0); }
|
||||
20% { transform: translate(-2px, 1px); filter: hue-rotate(20deg); }
|
||||
40% { transform: translate(2px, -1px); filter: hue-rotate(-15deg); }
|
||||
60% { transform: translate(-1px, 2px); filter: hue-rotate(10deg); }
|
||||
80% { transform: translate(1px, -2px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
#danger-vignette {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 999; /* below modals (1000/1001), above map/log */
|
||||
background: radial-gradient(ellipse at center, transparent 55%, var(--danger) 150%);
|
||||
opacity: var(--hemlighet-danger, 0);
|
||||
transition: opacity 1.5s ease;
|
||||
}
|
||||
|
||||
/* ── 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-faste .resource-bar-fill { background: var(--safe); }
|
||||
.bar-yra .resource-bar-fill { background: var(--kaos); }
|
||||
.bar-vav .resource-bar-fill { background: var(--vav); }
|
||||
|
||||
/* ── 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: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.map-tooltip::before { display: none !important; }
|
||||
|
||||
.map-tooltip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.map-tooltip-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-tooltip-inner span { padding-right: 2px; }
|
||||
|
||||
/* ── 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: #aaaacc;
|
||||
font-size: 10px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upgrade-btn .up-cost { font-size: 10px; color: #9999bb; }
|
||||
|
||||
/* ── 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 #1a1a2e;
|
||||
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); }
|
||||
|
||||
/* ── Intro screen ── */
|
||||
#intro-screen {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1002; /* above help modal and overlay */
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--bg);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
#intro-screen.visible { display: flex; }
|
||||
|
||||
.intro-scrim {
|
||||
background: linear-gradient(180deg, rgba(6,6,14,0) 0%, rgba(6,6,14,0.75) 35%, rgba(6,6,14,0.97) 100%);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 18vh 20px 8vh;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 8px;
|
||||
color: var(--dread);
|
||||
text-shadow: 0 0 30px rgba(204, 68, 204, 0.5);
|
||||
}
|
||||
|
||||
.intro-sub {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.intro-flavor {
|
||||
max-width: 480px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
margin: 8px 20px 4px;
|
||||
}
|
||||
|
||||
/* ── Overlays ── */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--bg);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#overlay.visible { display: flex; }
|
||||
|
||||
.overlay-scrim {
|
||||
background: radial-gradient(ellipse 55% 42% at center, rgba(6,6,14,0.8) 0%, rgba(6,6,14,0.5) 55%, rgba(6,6,14,0.12) 80%, transparent 100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
.pause-tag {
|
||||
margin-left: 8px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--kaos);
|
||||
}
|
||||
|
||||
/* Dim the world a touch while paused, so it reads as "held" rather than "broken" */
|
||||
#app.paused #map-container,
|
||||
#app.paused #panel-left,
|
||||
#app.paused #panel-right {
|
||||
filter: grayscale(0.5) brightness(0.75);
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Help / header buttons ── */
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--dim);
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.help-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
#abandon-btn:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
/* ── Help modal ── */
|
||||
.help-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 6, 14, 0.88);
|
||||
z-index: 1001; /* above Leaflet's z-index 400-1000 range */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.help-modal.visible { display: flex; }
|
||||
|
||||
.help-modal-inner {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
width: min(640px, 92vw);
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.help-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-modal-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.help-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dim);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.help-close:hover { color: var(--text); }
|
||||
|
||||
.help-modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--dim);
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.help-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
.help-list li::before { content: '— '; color: var(--dim); }
|
||||
.help-list strong { color: var(--accent); }
|
||||
.help-list em { color: var(--dread); font-style: normal; }
|
||||
|
||||
.help-flavor {
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
line-height: 1.8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#characters-modal-inner { width: min(860px, 94vw); }
|
||||
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.character-portrait {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 5;
|
||||
background: #08080f;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.character-portrait img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: grayscale(0.15) contrast(1.05);
|
||||
}
|
||||
|
||||
.character-portrait-fallback,
|
||||
.locked-portrait {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: var(--dim);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.character-card.locked .character-portrait.locked-portrait {
|
||||
background: repeating-linear-gradient(135deg, #08080f, #08080f 6px, #0d0d1a 6px, #0d0d1a 12px);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.character-card.locked .character-name,
|
||||
.character-card.locked .character-role {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.character-role {
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.character-bio {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.help-changelog-entry {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.help-changelog-version {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.help-changelog-date {
|
||||
font-weight: 400;
|
||||
color: var(--dim);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.archive-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
width: fit-content;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.archive-toggle-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dim);
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
.archive-toggle-btn + .archive-toggle-btn { border-left: 1px solid var(--border); }
|
||||
.archive-toggle-btn.active { background: var(--accent); color: #0d0d1a; }
|
||||
|
||||
.character-card:not(.locked) .character-portrait { cursor: pointer; }
|
||||
.character-card:not(.locked) .character-portrait:hover img { filter: grayscale(0) contrast(1.1) brightness(1.08); }
|
||||
|
||||
.lightbox {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(4, 4, 10, 0.94);
|
||||
z-index: 1100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
}
|
||||
.lightbox.visible { display: flex; }
|
||||
.lightbox img {
|
||||
max-width: min(90vw, 720px);
|
||||
max-height: 78vh;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.lightbox-caption {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.lightbox-blurb {
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
line-height: 1.7;
|
||||
max-width: 56ch;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
.lightbox-blurb:empty { display: none; }
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dim);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lightbox-close:hover { color: var(--text); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#app { grid-template-columns: 1fr; }
|
||||
.panel { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
#map { min-height: 250px; }
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
import type { State } from './engine.ts';
|
||||
import { UPGRADES } from './upgrades.ts';
|
||||
import { VERSION, CHANGELOG } from './data/version.ts';
|
||||
import { CHARACTERS } from './characters.ts';
|
||||
import { LORE } from './lore.ts';
|
||||
|
||||
export function initUI(
|
||||
app: HTMLElement,
|
||||
onUpgrade: (id: string) => void,
|
||||
onRestart: () => void,
|
||||
onEventChoice: (index: number) => void,
|
||||
onStart: () => void,
|
||||
onAbandon: () => void,
|
||||
onTogglePause: () => void,
|
||||
): void {
|
||||
app.innerHTML = `
|
||||
<div id="header">
|
||||
<div class="brand">THÖN</div>
|
||||
<div class="brand-sub">Skellefteå, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span><span id="pause-tag" class="pause-tag"></span></div>
|
||||
<div class="header-actions">
|
||||
<button id="pause-btn" class="help-btn" title="Pausa spelet">Pausa</button>
|
||||
<button id="characters-btn" class="help-btn" title="Bärarna">Bärarna</button>
|
||||
<button id="lore-btn" class="help-btn" title="Kodex">Kodex</button>
|
||||
<button id="abandon-btn" class="help-btn" title="Överge kvarnen">Avbryt</button>
|
||||
<button id="help-btn" class="help-btn" title="Hjälp & Ändringslogg">? v${VERSION}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="intro-screen" style="background-image:url('/img/intro.png')">
|
||||
<div class="intro-scrim">
|
||||
<div class="intro-title">THÖN</div>
|
||||
<div class="intro-sub">Skellefteå, Sverige</div>
|
||||
<p class="intro-flavor">
|
||||
Ett nedlagt sågverk vid älven. Fem stolar, en av dem alltid vänd mot vattnet.<br>
|
||||
Ni har hört tornet knaka i vinden som inte finns. Thön har inte hört er ännu.<br>
|
||||
Det är dags att väva.
|
||||
</p>
|
||||
<button class="overlay-btn" id="start-btn">Börja väva</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-left">
|
||||
<div class="panel-title">Resurser</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="resource-label">Bärare</div>
|
||||
<div class="resource-value" id="r-barare">1</div>
|
||||
</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="resource-label">Sken <span id="r-sken-rate" style="color:var(--dim);font-size:10px;"></span></div>
|
||||
<div class="resource-value" id="r-sken">0</div>
|
||||
</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="resource-label">Eko</div>
|
||||
<div class="resource-value" id="r-eko">0</div>
|
||||
</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="resource-label">Bräsch <span id="r-brasch-rate" style="color:var(--dread);font-size:10px;"></span></div>
|
||||
<div class="resource-value" style="color:var(--dread);" id="r-brasch">0</div>
|
||||
</div>
|
||||
|
||||
<div class="resource bar-faste" style="margin-top:16px;">
|
||||
<div class="resource-label">Fäste</div>
|
||||
<div class="resource-value" id="r-faste">100</div>
|
||||
<div class="resource-bar"><div class="resource-bar-fill" id="bar-faste" style="width:100%"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="resource bar-yra">
|
||||
<div class="resource-label">Yra</div>
|
||||
<div class="resource-value" id="r-yra">0</div>
|
||||
<div class="resource-bar"><div class="resource-bar-fill" id="bar-yra" style="width:0%"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="resource bar-vav" id="vav-section" style="display:none;">
|
||||
<div class="resource-label">Väv</div>
|
||||
<div class="resource-value" style="color:var(--vav);" id="r-vav">0</div>
|
||||
<div class="resource-bar"><div class="resource-bar-fill" id="bar-vav" 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">Väverk — kärnplatser</div>
|
||||
<div id="areas" style="font-size:11px;color:var(--dim);line-height:2;">
|
||||
<div id="area-kvarnen">○ Kvarnen</div>
|
||||
<div id="area-bonnstan">○ Bonnstan</div>
|
||||
<div id="area-sara">○ Sara kulturhus</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;">
|
||||
<div class="panel-title">Övriga platser</div>
|
||||
<div style="font-size:11px;color:var(--dim);line-height:2;">
|
||||
<div>Älvkajen</div>
|
||||
<div>Nordanå</div>
|
||||
<div>Landskyrkan</div>
|
||||
<div>Nya torget</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px;" id="investigator-section">
|
||||
<div class="panel-title">Grannar & myndigheter</div>
|
||||
<div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="danger-vignette"></div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="overlay">
|
||||
<div class="overlay-scrim">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="help-modal" class="help-modal">
|
||||
<div class="help-modal-inner" id="help-modal-inner">
|
||||
<div class="help-modal-header">
|
||||
<span class="help-modal-title">THÖN — Hjälp & Ändringslogg</span>
|
||||
<button id="help-close" class="help-close">✕</button>
|
||||
</div>
|
||||
<div class="help-modal-body">
|
||||
<section class="help-section">
|
||||
<div class="help-section-title">Hur man spelar</div>
|
||||
<ul class="help-list">
|
||||
<li><strong>Sken</strong> ackumuleras passivt. Använd det för att köpa åtgärder i vänsterpanelen.</li>
|
||||
<li><strong>Bräsch</strong> ökar när väven fördjupas. Hög bräsch låser upp nya platser och händelser.</li>
|
||||
<li><strong>Fäste</strong> sjunker när ni blir för genomskinliga för den här sidan. Når det noll är det slut.</li>
|
||||
<li><strong>Yra</strong> byggs upp naturligt och utlöser kaotiska händelser när den når 100.</li>
|
||||
<li><strong>Väv</strong> låses upp via Thörnepherian. Nå 100 för att fullborda väven.</li>
|
||||
<li>Klicka på <em>platser på kartan</em> för att besöka dem. Varje besök lämnar spår.</li>
|
||||
<li>Händelser inträffar automatiskt. De flesta är obehagliga. En del är värre.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="help-section">
|
||||
<div class="help-section-title">Om spelet</div>
|
||||
<p class="help-flavor">
|
||||
Thön är ett idle-spel om en liten grupp bärare i Skellefteå, Sverige.<br>
|
||||
Inspirerat av bandet Vildhjartas mytologi och lyrik — masker, bräscher, en värld som väver sig genom vår egen.<br>
|
||||
Thön är inte här ännu. Jobba på det.
|
||||
</p>
|
||||
</section>
|
||||
<section class="help-section">
|
||||
<div class="help-section-title">Ändringslogg</div>
|
||||
${CHANGELOG.map(e => `
|
||||
<div class="help-changelog-entry">
|
||||
<div class="help-changelog-version">v${e.version} <span class="help-changelog-date">${e.date}</span></div>
|
||||
<ul class="help-list">${e.changes.map(c => `<li>${c}</li>`).join('')}</ul>
|
||||
</div>
|
||||
`).join('')}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="characters-modal" class="help-modal">
|
||||
<div class="help-modal-inner" id="characters-modal-inner">
|
||||
<div class="help-modal-header">
|
||||
<span class="help-modal-title">Bärarna</span>
|
||||
<button id="characters-close" class="help-close">✕</button>
|
||||
</div>
|
||||
<div class="help-modal-body">
|
||||
<div class="archive-toggle" role="group" aria-label="Visningsläge">
|
||||
<button type="button" class="archive-toggle-btn active" data-archive="story">Under spelets gång</button>
|
||||
<button type="button" class="archive-toggle-btn" data-archive="full">Fullständigt arkiv</button>
|
||||
</div>
|
||||
<p class="help-flavor" id="characters-hint" style="margin-bottom:14px;">De som stått vid kvarnen. Vissa längre än andra.</p>
|
||||
<div id="characters-grid" class="characters-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lore-modal" class="help-modal">
|
||||
<div class="help-modal-inner" id="lore-modal-inner">
|
||||
<div class="help-modal-header">
|
||||
<span class="help-modal-title">Kodex</span>
|
||||
<button id="lore-close" class="help-close">✕</button>
|
||||
</div>
|
||||
<div class="help-modal-body">
|
||||
<p class="help-flavor" style="margin-bottom:14px;">Fragment, dagböcker och anteckningar ni samlat på er. Fylls i allteftersom väven fördjupas.</p>
|
||||
<div id="lore-list"></div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div id="lightbox" class="lightbox">
|
||||
<button id="lightbox-close" class="lightbox-close" aria-label="Stäng">✕</button>
|
||||
<img id="lightbox-img" alt="">
|
||||
<div id="lightbox-caption" class="lightbox-caption"></div>
|
||||
<p id="lightbox-blurb" class="lightbox-blurb"></p>
|
||||
</div>
|
||||
`);
|
||||
|
||||
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
|
||||
document.getElementById('start-btn')!.addEventListener('click', onStart);
|
||||
document.getElementById('abandon-btn')!.addEventListener('click', () => {
|
||||
if (window.confirm('Överge kvarnen? Väven avbryts och ni går skilda vägar.')) onAbandon();
|
||||
});
|
||||
document.getElementById('pause-btn')!.addEventListener('click', onTogglePause);
|
||||
|
||||
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'));
|
||||
document.getElementById('help-close')!.addEventListener('click', () => helpModal.classList.remove('visible'));
|
||||
helpModal.addEventListener('click', (e) => { if (!helpInner.contains(e.target as Node)) helpModal.classList.remove('visible'); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === '?') helpModal.classList.toggle('visible'); });
|
||||
|
||||
const charactersModal = document.getElementById('characters-modal')!;
|
||||
const charactersInner = document.getElementById('characters-modal-inner')!;
|
||||
document.getElementById('characters-btn')!.addEventListener('click', () => {
|
||||
renderCharacterGrid(latestState);
|
||||
charactersModal.classList.toggle('visible');
|
||||
});
|
||||
document.getElementById('characters-close')!.addEventListener('click', () => charactersModal.classList.remove('visible'));
|
||||
charactersModal.addEventListener('click', (e) => { if (!charactersInner.contains(e.target as Node)) charactersModal.classList.remove('visible'); });
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('.archive-toggle-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.archive-toggle-btn').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
archiveMode = btn.dataset['archive'] === 'full';
|
||||
document.getElementById('characters-hint')!.textContent = archiveMode
|
||||
? 'Fullständigt arkiv — alla porträtt, oavsett hur långt ni kommit. Klicka för att se dem i fullstorlek.'
|
||||
: 'De som stått vid kvarnen. Vissa längre än andra.';
|
||||
renderCharacterGrid(latestState);
|
||||
});
|
||||
});
|
||||
|
||||
const lightbox = document.getElementById('lightbox')!;
|
||||
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
|
||||
const lightboxCaption = document.getElementById('lightbox-caption')!;
|
||||
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
|
||||
document.getElementById('characters-grid')!.addEventListener('click', (e) => {
|
||||
const portrait = (e.target as Element).closest('.character-card:not(.locked) .character-portrait');
|
||||
if (!portrait) return;
|
||||
const card = portrait.closest<HTMLElement>('[data-char]');
|
||||
const img = portrait.querySelector('img');
|
||||
if (!card || !img?.src) return;
|
||||
lightboxImg.src = img.src;
|
||||
lightboxCaption.textContent = card.querySelector('.character-name')?.textContent ?? '';
|
||||
lightboxBlurb.textContent = '';
|
||||
lightbox.classList.add('visible');
|
||||
});
|
||||
document.getElementById('lightbox-close')!.addEventListener('click', () => lightbox.classList.remove('visible'));
|
||||
lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('visible'); });
|
||||
|
||||
const loreModal = document.getElementById('lore-modal')!;
|
||||
const loreInner = document.getElementById('lore-modal-inner')!;
|
||||
document.getElementById('lore-btn')!.addEventListener('click', () => {
|
||||
renderLoreList(latestState);
|
||||
loreModal.classList.toggle('visible');
|
||||
});
|
||||
document.getElementById('lore-close')!.addEventListener('click', () => loreModal.classList.remove('visible'));
|
||||
loreModal.addEventListener('click', (e) => { if (!loreInner.contains(e.target as Node)) loreModal.classList.remove('visible'); });
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (lightbox.classList.contains('visible')) { lightbox.classList.remove('visible'); return; }
|
||||
if (loreModal.classList.contains('visible')) { loreModal.classList.remove('visible'); return; }
|
||||
if (charactersModal.classList.contains('visible')) { charactersModal.classList.remove('visible'); return; }
|
||||
if (helpModal.classList.contains('visible')) { helpModal.classList.remove('visible'); return; }
|
||||
});
|
||||
|
||||
const mapContainer = document.getElementById('map-container')!;
|
||||
mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;';
|
||||
const mapEl = document.getElementById('map')!;
|
||||
mapEl.style.cssText = 'flex:0 0 45%;min-height:0;max-height:45%;';
|
||||
const logEl = document.getElementById('log')!;
|
||||
logEl.style.cssText = 'flex:1 1 0;overflow-y:auto;border-top:1px solid var(--border);min-height:0;';
|
||||
|
||||
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']);
|
||||
});
|
||||
}
|
||||
|
||||
let latestState: State | null = null;
|
||||
let archiveMode = false;
|
||||
|
||||
export function renderState(state: State): void {
|
||||
latestState = state;
|
||||
setText('r-barare', String(state.barare));
|
||||
setText('r-sken', fmt(state.sken));
|
||||
setText('r-sken-rate', `+${(state.skenRate * state.barare).toFixed(1)}/s`);
|
||||
setText('r-eko', fmt(state.eko));
|
||||
setText('r-brasch', fmt(state.brasch));
|
||||
setText('r-brasch-rate', state.braschRate > 0 ? `+${state.braschRate.toFixed(1)}/s` : '');
|
||||
setText('r-faste', Math.floor(state.faste).toString());
|
||||
setText('r-yra', Math.floor(state.yra).toString());
|
||||
setBar('bar-faste', state.faste);
|
||||
setBar('bar-yra', state.yra);
|
||||
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty('--dread-intensity', Math.min(1, state.brasch / 150).toFixed(3));
|
||||
root.setProperty('--hemlighet-danger', (state.faste < 40 ? (1 - state.faste / 40) * 0.35 : 0).toFixed(3));
|
||||
|
||||
const vavSection = document.getElementById('vav-section')!;
|
||||
if (state.vavUnlocked) {
|
||||
vavSection.style.display = '';
|
||||
setText('r-vav', Math.floor(state.vav).toString());
|
||||
setBar('bar-vav', state.vav);
|
||||
}
|
||||
|
||||
const phaseEl = document.getElementById('phase-tag')!;
|
||||
if (state.vav >= 50) {
|
||||
phaseEl.textContent = 'Sent skede'; phaseEl.className = 'phase-tag late';
|
||||
} else if (state.brasch >= 30) {
|
||||
phaseEl.textContent = 'Mitt skede'; phaseEl.className = 'phase-tag mid';
|
||||
} else {
|
||||
phaseEl.textContent = 'Tidigt skede'; phaseEl.className = 'phase-tag early';
|
||||
}
|
||||
|
||||
setArea('area-kvarnen', state.areas.kvarnen, 'Kvarnen');
|
||||
setArea('area-bonnstan', state.areas.bonnstan, 'Bonnstan');
|
||||
setArea('area-sara', state.areas.sara, 'Sara kulturhus');
|
||||
|
||||
const inv = document.getElementById('investigator-status')!;
|
||||
if (state.faste < 20) {
|
||||
inv.textContent = 'Nära — väldigt nära'; inv.style.color = 'var(--danger)';
|
||||
} else if (state.faste < 50) {
|
||||
inv.textContent = 'Misstänksamma'; inv.style.color = 'var(--kaos)';
|
||||
} else {
|
||||
inv.textContent = 'Inaktiva'; inv.style.color = 'var(--safe)';
|
||||
}
|
||||
|
||||
renderUpgrades(state);
|
||||
renderLog(state);
|
||||
|
||||
document.getElementById('intro-screen')!.classList.toggle('visible', state.phase === 'intro');
|
||||
|
||||
const overlay = document.getElementById('overlay')!;
|
||||
if (state.phase === 'won' || state.phase === 'lost' || state.phase === 'abandoned') {
|
||||
showOverlay(state);
|
||||
} else {
|
||||
overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
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 = '';
|
||||
|
||||
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));
|
||||
|
||||
const key = available.map(u => {
|
||||
const cost = u.cost(state);
|
||||
const canAfford = (!cost.sken || state.sken >= cost.sken) && (!cost.brasch || state.brasch >= cost.brasch) && (!cost.eko || state.eko >= cost.eko);
|
||||
return `${u.id}:${canAfford}:${cost.sken ?? ''}`;
|
||||
}).join('|');
|
||||
|
||||
if (key === lastUpgradeKey) return;
|
||||
lastUpgradeKey = key;
|
||||
|
||||
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 cost = u.cost(state);
|
||||
const canAfford = (!cost.sken || state.sken >= cost.sken) && (!cost.brasch || state.brasch >= cost.brasch) && (!cost.eko || state.eko >= cost.eko);
|
||||
const costs = [
|
||||
cost.sken ? `${cost.sken} sken` : '',
|
||||
cost.brasch ? `${cost.brasch} bräsch` : '',
|
||||
cost.eko ? `${cost.eko} eko` : '',
|
||||
].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('');
|
||||
}
|
||||
|
||||
let lastLogSeq = -1;
|
||||
let typewriterTimer: number | undefined;
|
||||
|
||||
const GLITCH_CHARS = ['☿', '☠', 'ψ', 'Ψ', '⛧', '҂'];
|
||||
|
||||
function glitchChar(dreadIntensity: number): string | null {
|
||||
const chance = Math.min(0.18, dreadIntensity * 0.22);
|
||||
if (Math.random() < chance) return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)]!;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderLog(state: State): void {
|
||||
const log = document.getElementById('log')!;
|
||||
if (state.logSeq === lastLogSeq) return;
|
||||
const isFirstRender = lastLogSeq === -1;
|
||||
lastLogSeq = state.logSeq;
|
||||
|
||||
const [newest, ...rest] = state.log;
|
||||
log.innerHTML = (newest ? `<div class="log-entry ${newest.kind}" id="log-newest"></div>` : '')
|
||||
+ rest.map(e => `<div class="log-entry ${e.kind}">${e.text}</div>`).join('');
|
||||
log.scrollTop = 0;
|
||||
|
||||
if (typewriterTimer !== undefined) { window.clearInterval(typewriterTimer); typewriterTimer = undefined; }
|
||||
|
||||
if (newest) {
|
||||
const el = document.getElementById('log-newest')!;
|
||||
const animate = !isFirstRender && state.phase === 'playing' && (newest.kind === 'event' || newest.kind === 'yra');
|
||||
if (!animate) {
|
||||
el.textContent = newest.text;
|
||||
} else {
|
||||
const dreadIntensity = Math.min(1, state.brasch / 150);
|
||||
const text = newest.text;
|
||||
const stepMs = text.length > 90 ? 4 : 8;
|
||||
let i = 0;
|
||||
typewriterTimer = window.setInterval(() => {
|
||||
i += 1;
|
||||
el.textContent = text.slice(0, i).split('').map(ch => (ch === ' ' ? ch : glitchChar(dreadIntensity) ?? ch)).join('');
|
||||
if (i >= text.length) {
|
||||
window.clearInterval(typewriterTimer);
|
||||
typewriterTimer = undefined;
|
||||
}
|
||||
}, stepMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.log[0]?.kind === 'yra') {
|
||||
const app = document.getElementById('app')!;
|
||||
app.classList.remove('kaos-glitch');
|
||||
void app.offsetWidth;
|
||||
app.classList.add('kaos-glitch');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCharacterGrid(state: State | null): void {
|
||||
const grid = document.getElementById('characters-grid')!;
|
||||
const met = new Set(state?.usedEventIds ?? []);
|
||||
grid.innerHTML = CHARACTERS.map(c => {
|
||||
const known = archiveMode || !c.unlockEventId || met.has(c.unlockEventId);
|
||||
if (!known) {
|
||||
return `
|
||||
<div class="character-card locked">
|
||||
<div class="character-portrait locked-portrait">?</div>
|
||||
<div class="character-name">???</div>
|
||||
<div class="character-role">Inte mött än.</div>
|
||||
</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="character-card" data-char="${c.id}">
|
||||
<div class="character-portrait"><img alt="${c.name}" loading="lazy"></div>
|
||||
<div class="character-name">${c.name}</div>
|
||||
<div class="character-role">${c.role}</div>
|
||||
<div class="character-bio">${c.bio}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
for (const c of CHARACTERS) {
|
||||
const card = grid.querySelector(`[data-char="${c.id}"]`);
|
||||
const img = card?.querySelector('img');
|
||||
if (!img) continue;
|
||||
img.addEventListener('error', () => {
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'character-portrait-fallback';
|
||||
fallback.textContent = c.name.charAt(0);
|
||||
img.replaceWith(fallback);
|
||||
}, { once: true });
|
||||
img.src = c.img;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoreList(state: State | null): void {
|
||||
const list = document.getElementById('lore-list')!;
|
||||
if (!list) return;
|
||||
const unlocked = state ? LORE.filter(l => l.unlock(state)) : [];
|
||||
const lockedCount = LORE.length - unlocked.length;
|
||||
list.innerHTML = unlocked.map(l => `
|
||||
<div class="help-section" style="margin-bottom:16px;">
|
||||
<div class="help-section-title" style="color:var(--accent);border-bottom:none;padding-bottom:0;">${l.title}</div>
|
||||
<p class="help-flavor" style="font-style:normal;color:var(--text);">${l.body}</p>
|
||||
</div>
|
||||
`).join('') + (lockedCount > 0
|
||||
? `<p class="help-flavor">…${lockedCount} fragment kvarstår olästa. Fördjupa väven för att hitta fler.</p>`
|
||||
: '');
|
||||
}
|
||||
|
||||
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') {
|
||||
ov.style.backgroundImage = "url('/img/win.png')";
|
||||
title.textContent = 'VÄVEN SLUTEN';
|
||||
title.className = 'overlay-title won';
|
||||
body.textContent = 'Väven sluter sig. Tornet slutar knaka. Älven är stilla under isen — alltför stilla. Skellefteå fortsätter som vanligt. Det är det värsta.';
|
||||
} else if (state.phase === 'abandoned') {
|
||||
ov.style.backgroundImage = "url('/img/fail.png')";
|
||||
title.textContent = 'ÖVERGIVET';
|
||||
title.className = 'overlay-title lost';
|
||||
body.textContent = 'Ni går ut ur kvarnen och stänger dörren. Ingen låser den. Thön väntar. Det har alltid väntat.';
|
||||
} else {
|
||||
ov.style.backgroundImage = "url('/img/fail.png')";
|
||||
title.textContent = 'FÖRLORAT FÄSTE';
|
||||
title.className = 'overlay-title lost';
|
||||
body.textContent = state.lostReason ?? 'Ni tappade taget.';
|
||||
}
|
||||
}
|
||||
|
||||
export function showLocationArt(id: string, label: string, blurb: string): void {
|
||||
const src = `/img/locations/${id}.png`;
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
const lightbox = document.getElementById('lightbox')!;
|
||||
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
|
||||
const lightboxCaption = document.getElementById('lightbox-caption')!;
|
||||
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
|
||||
lightboxImg.src = src;
|
||||
lightboxCaption.textContent = label;
|
||||
lightboxBlurb.textContent = blurb;
|
||||
lightbox.classList.add('visible');
|
||||
};
|
||||
probe.src = src;
|
||||
}
|
||||
|
||||
export function setPausedUI(paused: boolean): void {
|
||||
const btn = document.getElementById('pause-btn')!;
|
||||
btn.textContent = paused ? 'Fortsätt' : 'Pausa';
|
||||
document.getElementById('app')!.classList.toggle('paused', paused);
|
||||
setText('pause-tag', paused ? '⏸ Pausad' : '');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import type { State } from './engine.ts';
|
||||
|
||||
export interface Upgrade {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
cost: (s: State) => Partial<{ sken: number; brasch: number; eko: number }>;
|
||||
effect: (s: State) => void;
|
||||
available: (s: State) => boolean;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
export const UPGRADES: Upgrade[] = [
|
||||
{
|
||||
id: 'recruit_carrier',
|
||||
name: 'Kalla på en bärare',
|
||||
desc: '+1 Bärare. De hörde tornet knaka i vinden och kunde inte sluta tänka på det. Nu vet de varför. Fler munnar att mätta — priset stiger för varje ny bärare.',
|
||||
cost: s => ({ sken: Math.round(20 * Math.pow(1.35, s.barare)) }),
|
||||
effect: s => { s.barare += 1; },
|
||||
available: () => true,
|
||||
once: false,
|
||||
},
|
||||
{
|
||||
id: 'exert_echo',
|
||||
name: 'Sprid ekot',
|
||||
desc: 'Några ord här, ett par där. En tjänsteman glömmer att skriva rapporten. En granne glömmer vad hen såg genom fönstret. +fäste, men eko sprider sig ändå lite. +yra',
|
||||
cost: () => ({ eko: 30 }),
|
||||
effect: s => { s.faste = Math.min(100, s.faste + 8); s.yra = Math.min(100, s.yra + 3); },
|
||||
available: s => s.barare >= 2,
|
||||
once: false,
|
||||
},
|
||||
{
|
||||
id: 'first_meeting',
|
||||
name: 'Möte vid kvarnen',
|
||||
desc: 'Det gamla sågverket vid älven har stått tomt i tjugo år. Elen fungerar ändå. Ingen av er minns vem som betalar räkningen. +1 sken/s.',
|
||||
cost: () => ({ sken: 30 }),
|
||||
effect: s => { s.skenRate += 1; s.areas.kvarnen = true; },
|
||||
available: s => s.barare >= 2 && !s.upgrades.has('first_meeting'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'the_language',
|
||||
name: 'Lär er Thörnepherian',
|
||||
desc: 'Orden hittades ristade i kugghjulens undersida, ett språk som inte liknar något ni känner igen förrän ni börjat läsa det högt. Sedan liknar det allt. Lingvisten ni anlitade slutade svara efter tredje sidan. Hon skickade resten av översättningen ändå, med posten, utan avsändare.',
|
||||
cost: () => ({ sken: 80 }),
|
||||
effect: s => { s.braschRate += 0.5; s.vavUnlocked = true; },
|
||||
available: s => s.barare >= 3 && s.upgrades.has('first_meeting') && !s.upgrades.has('the_language'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'the_mask',
|
||||
name: 'Skär den första masken',
|
||||
desc: 'Ingen av er kommer ihåg vem som föreslog det, bara att materialet redan låg och väntade i kvarnens källare, format som om det visste vems ansikte det skulle bli. Den som bär den ser bättre i mörkret nu. Det är inte alltid en fördel.',
|
||||
cost: () => ({ sken: 150, brasch: 30 }),
|
||||
effect: s => { s.skenRate += 1.5; s.faste = Math.max(0, s.faste - 10); },
|
||||
available: s => s.upgrades.has('the_language') && !s.upgrades.has('the_mask'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'leech_rite',
|
||||
name: 'Iglens rit',
|
||||
desc: 'Ni ger något ifrån er och Thön tar det gärna, men aldrig bara det ni erbjöd. Det tar lite till varje gång, tyst, från kanten av allt ni är. Ni märker det knappt. Det är poängen.',
|
||||
cost: () => ({ sken: 120, brasch: 20 }),
|
||||
effect: s => { s.braschRate += 1; s.skenRate += 0.5; },
|
||||
available: s => s.upgrades.has('the_language') && !s.upgrades.has('leech_rite'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'bonnstan_stugor',
|
||||
name: 'Öppna en kyrkstuga i Bonnstan',
|
||||
desc: 'Dörren var aldrig låst, bara stängd tillräckligt länge för att alla skulle glömma att den fanns. Innanför väntar ett rum format efter något som inte är en kyrka, fast det ser ut som en.',
|
||||
cost: () => ({ sken: 200, brasch: 40 }),
|
||||
effect: s => { s.areas.bonnstan = true; s.braschRate += 1.5; s.yraRate += 0.2; },
|
||||
available: s => s.upgrades.has('leech_rite') && s.brasch >= 50 && !s.upgrades.has('bonnstan_stugor'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'sara_beacon',
|
||||
name: 'Fyren i trätornet',
|
||||
desc: 'Tornet reser sig arton våningar över torget, ett trästativ format som en vriden ryggrad. Det är ingen metafor — arkitekten drömde ritningen, sa han, natt efter natt, tills han bara behövde skriva ner den. Ni ber det svara nu. Det gör det, i ett register under hörbart.',
|
||||
cost: () => ({ sken: 500, brasch: 100 }),
|
||||
effect: s => { s.areas.sara = true; s.braschRate += 3; s.vavRate += 0.15; },
|
||||
available: s => s.upgrades.has('bonnstan_stugor') && s.brasch >= 100 && !s.upgrades.has('sara_beacon'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'grand_weave_prep',
|
||||
name: 'Förberedelser för Den Stora Väven',
|
||||
desc: 'Allt har lett hit. Tornet pekar. Bonnstan svarar. Älven backar som om den vet vad som kommer. Thörnepherian är nu fullständigt översatt. Den sista sidan skrev om sig själv i natt, från punkt till versal, medan ingen tittade.',
|
||||
cost: () => ({ sken: 900, brasch: 250 }),
|
||||
effect: s => { s.vavRate += 0.35; s.yraRate += 0.5; },
|
||||
available: s => s.upgrades.has('sara_beacon') && s.vav >= 50 && !s.upgrades.has('grand_weave_prep'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'sunken_choir',
|
||||
name: 'Kören under isen',
|
||||
desc: 'Rösterna under älvens is har börjat svara i takt med era. Ni behöver bara lyssna tillbaka. Det underlättar. Det är också svårt att sluta göra, även när ni vill.',
|
||||
cost: () => ({ sken: 1400, brasch: 300 }),
|
||||
effect: s => { s.vavRate += 0.25; s.skenRate += 1; },
|
||||
available: s => s.upgrades.has('grand_weave_prep') && s.brasch >= 300 && !s.upgrades.has('sunken_choir'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'nine_masks_carved',
|
||||
name: 'Nio masker skurna',
|
||||
desc: 'Var och en av er bär nu sitt eget ansikte, format i trä från kvarnens golv. Ingen minns exakt när ni bytte till att alltid ha dem på. Det känns fel att fråga.',
|
||||
cost: () => ({ sken: 2200, brasch: 350 }),
|
||||
effect: s => { s.vavRate += 0.25; s.yraRate += 0.3; },
|
||||
available: s => s.upgrades.has('sunken_choir') && s.barare >= 9 && !s.upgrades.has('nine_masks_carved'),
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
id: 'deepen_weave',
|
||||
name: 'Fördjupa väven',
|
||||
desc: 'Ännu ett lager av trådar under det förra. Sken är inget värt om det bara samlas i en burk. Varje fördjupning kostar mer än den förra — Thön blir kräsnare ju närmre ni kommer.',
|
||||
cost: s => ({ sken: Math.round(350 * Math.pow(1.55, weaveDeepenings(s))), brasch: 60 }),
|
||||
effect: s => { s.vavRate += 0.15; },
|
||||
available: s => s.upgrades.has('grand_weave_prep'),
|
||||
once: false,
|
||||
},
|
||||
{
|
||||
id: 'the_mask_leads',
|
||||
name: 'Masken leder er',
|
||||
desc: 'Ni behöver inte längre besluta vart ni går. Fötterna vet vägen innan huvudet gör det. Bekvämt. Det är också första gången ingen av er är helt säker på vems tankar det egentligen är.',
|
||||
cost: () => ({ sken: 600, brasch: 80 }),
|
||||
effect: () => { /* handled by main.ts's tick loop — see WHISPER_INTERVAL_TICKS */ },
|
||||
available: s => s.upgrades.has('sara_beacon') && !s.upgrades.has('the_mask_leads'),
|
||||
once: true,
|
||||
},
|
||||
];
|
||||
|
||||
function weaveDeepenings(s: State): number {
|
||||
return Math.round((s.vavRate - 0.5) / 0.15);
|
||||
}
|
||||
Reference in New Issue
Block a user