Location thumbnails on the map, full-art archive mode, more content
- Map markers now show a tiny thumbnail preview in their tooltip when art exists for that location (same missing-asset-is-fine fallback as character portraits — onerror just removes the broken img). - Two new locations, both free-roam from the start: Möllevångstorget and Malmö universitet (the latter ties into the researcher character's arc). VISITABLE_LOCATION_IDS picks them up automatically since it's derived from the data, not a separate list. - Sällskapet gets a second mode: "Fullständigt arkiv" shows every character's real portrait and bio regardless of story progress (bypassing the usedEventIds lock), and clicking any known portrait opens a lightbox at full size instead of the cropped card thumbnail. - All 9 character portraits are now in and wired (fixed two more filename mismatches along the way: anneli-karin and the rest use hyphens, not underscores, matching what was actually generated). - More content: 2 new location-visit variants each for the two new areas, 6 new Standard events (including a found 1930s photograph that seems to show Lindqvist in a room nobody recognizes), 4 new Kaos events tied to the new locations and existing ones. - Caught and fixed a bug in my own new content before commit: an event's requires.completedEventId pointed at an upgrade id, which can never appear in usedEventIds — switched to a minRitual check that actually gates on the right precondition. Verified: 15/15 tests, clean typecheck and build, and a real headless-browser pass — all 9 map markers present, full-archive mode correctly bypassing locks, and the lightbox opening at full size with a caption. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
BIN
public/img/characters/anneli-karin.png
Normal file
BIN
public/img/characters/anneli-karin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/journalist.png
Normal file
BIN
public/img/characters/journalist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/researcher.png
Normal file
BIN
public/img/characters/researcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/img/characters/sailor.png
Normal file
BIN
public/img/characters/sailor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -57,7 +57,7 @@ export const CHARACTERS: Character[] = [
|
||||
role: 'Talar i sömnen. Inte på svenska.',
|
||||
bio: 'Talar i sömnen på ett språk ingen känner igen. Inspelningen, uppspelad baklänges, var lika obegriplig — fast på ett annat sätt.',
|
||||
unlockEventId: 'anneli_dream',
|
||||
img: '/img/characters/anneli_karin.png',
|
||||
img: '/img/characters/anneli-karin.png',
|
||||
},
|
||||
{
|
||||
id: 'sailor',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const VERSION = '0.5.0';
|
||||
export const VERSION = '0.6.0';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -7,6 +7,17 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.6.0',
|
||||
date: '2026-08-01',
|
||||
changes: [
|
||||
'Alla porträtt i Sällskapet är nu på plats',
|
||||
'Nytt läge i Sällskapet: Fullständigt arkiv — se alla porträtt oavsett hur långt ni kommit, klicka för fullstorlek',
|
||||
'Platser på kartan visar nu en liten bild i förhandsvisningen (när konst finns för platsen)',
|
||||
'Två nya platser: Möllevångstorget och Malmö universitet, båda besökbara direkt från start',
|
||||
'Fler händelser tillagda, inklusive ett fotografi från 1930-talet som inte borde finnas',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.5.0',
|
||||
date: '2026-08-01',
|
||||
|
||||
@@ -386,6 +386,28 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
effect: { kaos: 8 },
|
||||
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
|
||||
},
|
||||
mollan: {
|
||||
texts: [
|
||||
'Marknadsstånden på Möllan säljer samma fisk som alltid, men ingen minns vilket hav den kom från. Den smakar rätt ändå. +kaos',
|
||||
'Ett gäng ungdomar sjunger något på ett språk som låter som tre olika språk samtidigt, i perfekt harmoni. +kaos',
|
||||
'Fontänen på torget rinner uppåt i en sekund innan den rättar till sig. Ingen annan såg det. Du är säker på att du såg det. +kaos',
|
||||
'En gatuförsäljare erbjuder en frukt du inte känner igen, från ett land ingen kan namnge. Den är söt. Den är för söt. +kaos',
|
||||
'Graffitin på muren mot innergården ändras varje gång du vänder ryggen till, alltid samma motiv, aldrig samma vinkel. +kaos',
|
||||
],
|
||||
effect: { kaos: 10 },
|
||||
lockedText: 'Möllevångstorget är fullt av liv, musik, dofter från femtio kök samtidigt. Det är svårt att höra sig själv tänka här. Det är kanske poängen.',
|
||||
},
|
||||
universitet: {
|
||||
texts: [
|
||||
'Biblioteket har ett rum för sällan efterfrågade samlingar. Din kod fungerar där ingen minns att du fått en. +andakt +dread',
|
||||
'En föreläsningssal står tom men whiteboarden är full av ekvationer ingen lärare skrivit. Den sista raden är inte matematik. +andakt +dread',
|
||||
'Doktoranderna på plan tre pratar lägre än vanligt när du går förbi. En av dem tittar upp för länge. +andakt +dread',
|
||||
'Bibliotekets sökkatalog listar en avhandling om Skriften, publicerad om tre år. Ingen kan checka ut den förrän dess. +andakt +dread',
|
||||
'Studenthälsan har en anmärkningsvärt hög andel patienter med sömnproblem i just den här korridoren. Ingen har satt det i samband med något. +andakt +dread',
|
||||
],
|
||||
effect: { andakt: 12, dread: 6 },
|
||||
lockedText: 'Malmö universitet vid Niagara. Glas och stål och lysrör som surrar på en frekvens du inte borde kunna höra genom väggarna.',
|
||||
},
|
||||
};
|
||||
|
||||
export const VISITABLE_LOCATION_IDS = Object.keys(LOCATION_VISITS);
|
||||
|
||||
@@ -743,6 +743,71 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
weight: 5,
|
||||
requires: { minTroende: 4 },
|
||||
},
|
||||
{
|
||||
id: 'mollan_market_stall',
|
||||
text: 'En marknadshandlare på Möllevångstorget säger att hon känner igen er, fast ingen av er har handlat hos henne förut. Hon lägger till en extra påse i kassen utan att fråga. Innehållet är inte på svenska.',
|
||||
effect: { andakt: 12, kaos: 6 },
|
||||
weight: 6,
|
||||
requires: { unlockedArea: 'rosengard' },
|
||||
},
|
||||
{
|
||||
id: 'university_manuscript_hit',
|
||||
text: 'En sökning på universitetsbiblioteket ger en enda träff på en fras ur Skriften, i en avhandling om maritim folktro från 1958. Författaren dog samma år. Boken är utlånad. Den har alltid varit utlånad.',
|
||||
effect: { dread: 14, ritual: 3 },
|
||||
weight: 5,
|
||||
requires: { minRitual: 15 },
|
||||
},
|
||||
{
|
||||
id: 'choice_survey_call',
|
||||
text: 'Statistiska centralbyrån ringer och frågar om er upplevda livskvalitet, på en skala ett till tio. Frågorna blir successivt mer specifika. Till slut frågar hen om ni "hör röster som inte är era egna, och om ni lyssnar."',
|
||||
effect: {},
|
||||
weight: 5,
|
||||
requires: { minDread: 25 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Svara ärligt',
|
||||
effect: { hemlighet: -15, dread: 5 },
|
||||
resultText: 'Hen tackar för svaret utan att låta förvånad. Samtalet avslutas normalt. Ni är inte säkra på vem ni just pratade med.',
|
||||
},
|
||||
{
|
||||
text: 'Ljug och lägg på',
|
||||
effect: { hemlighet: 5, kaos: 8 },
|
||||
resultText: 'Ni lägger på mitt i en mening. Telefonen ringer igen tre sekunder senare. Ni svarar inte.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bus_2_driver',
|
||||
text: 'Föraren på linje 2 hälsar er vid namn när ni kliver på, trots att ingen av er sagt det. Bussen kör en rutt som inte finns i tidtabellen och släpper av er exakt utanför källaren, tio minuter tidigare än ni räknat med.',
|
||||
effect: { dread: 9, andakt: 6 },
|
||||
weight: 6,
|
||||
requires: { minDread: 15 },
|
||||
},
|
||||
{
|
||||
id: 'malmo_live_reflection',
|
||||
text: 'Glasfasaden på Malmö Live speglar en himmel med fel antal moln jämfört med den riktiga. Ni räknar två gånger. Talen stämmer inte överens med varandra heller.',
|
||||
effect: { kaos: 9, dread: 6 },
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'choice_old_photograph',
|
||||
text: 'En gammal svartvit fotografi dyker upp i källaren, ingen minns var den kom ifrån. Den föreställer nio personer runt ett bord i exakt samma rum. Kläderna är från 1930-talet. En av personerna ser ut som Broder Lindqvist.',
|
||||
effect: {},
|
||||
weight: 4,
|
||||
requires: { minDread: 70, minRitual: 1 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Visa fotografiet för Lindqvist',
|
||||
effect: { dread: 15, andakt: 10 },
|
||||
resultText: 'Lindqvist tittar länge. Han säger att han känner igen rummet men inte människorna. Han lägger fotografiet i sin ficka istället för att lämna tillbaka det.',
|
||||
},
|
||||
{
|
||||
text: 'Göm fotografiet',
|
||||
effect: { kaos: 12, hemlighet: -5 },
|
||||
resultText: 'Ni lägger det längst ner i en låda. Nästa vecka ligger det på bordet igen, överst.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
|
||||
@@ -977,4 +1042,32 @@ export const KAOS_EVENTS: GameEvent[] = [
|
||||
weight: 6,
|
||||
requires: { minDread: 35 },
|
||||
},
|
||||
{
|
||||
id: 'k_mollan_food_truck',
|
||||
text: 'En food truck som ingen beställt eller godkänt har parkerat på Möllevångstorget och serverar en rätt som inte står på skylten till alla som frågar. Kommunen kan inte hitta några tillståndspapper. Truckens ägare finns inte i något register.',
|
||||
effect: { kaos: -40, troende: 2, andakt: 20 },
|
||||
weight: 7,
|
||||
requires: { minTroende: 3 },
|
||||
},
|
||||
{
|
||||
id: 'k_university_thesis_defense',
|
||||
text: 'En doktorandförsvar vid universitetet avbryts när opponenten ställer en fråga ingen i publiken hör men alla ser avhandlingsförfattaren svara på. Betyget blir godkänt med beröm. Ingen av professorerna minns frågan efteråt.',
|
||||
effect: { kaos: -45, ritual: 6, dread: 20 },
|
||||
weight: 5,
|
||||
requires: { minRitual: 10 },
|
||||
},
|
||||
{
|
||||
id: 'k_all_clocks_torso',
|
||||
text: 'Alla digitala klockor inom synhåll från Turning Torso har visat samma sekund i över en timme. Analoga klockor går som vanligt. Ingen teknisk avdelning kan förklara det, och ingen verkar särskilt intresserad av att försöka.',
|
||||
effect: { kaos: -60, dread: 50, ritual: 8 },
|
||||
weight: 4,
|
||||
requires: { unlockedArea: 'torso', minDread: 70 },
|
||||
},
|
||||
{
|
||||
id: 'k_ribersborg_tide',
|
||||
text: 'Tidvattnet vid Ribersborg går ut längre än någonsin uppmätt, i tio minuter, och avslöjar stenformationer på havsbotten som inte finns på någon sjökarta. Sedan kommer vattnet tillbaka som om inget hänt. Fotografierna på nätet försvinner inom en timme.',
|
||||
effect: { kaos: -55, dread: 45, ritual: 6 },
|
||||
weight: 4,
|
||||
requires: { minDread: 90 },
|
||||
},
|
||||
];
|
||||
|
||||
18
src/map.ts
18
src/map.ts
@@ -18,8 +18,15 @@ const LOCATIONS: Omit<MapMarker, 'dreadLevel'>[] = [
|
||||
{ id: 'ribersborg',lat: 55.6063, lng: 12.9652, label: 'Ribersborgsstranden' },
|
||||
{ id: 'davidshall',lat: 55.5963, lng: 13.0021, label: 'Davidshallsgatan' },
|
||||
{ id: 'stortorget',lat: 55.6047, lng: 13.0038, label: 'Stortorget' },
|
||||
{ id: 'mollan', lat: 55.5904, lng: 13.0203, label: 'Möllevångstorget' },
|
||||
{ id: 'universitet', lat: 55.5721, lng: 12.9998, label: 'Malmö universitet' },
|
||||
];
|
||||
|
||||
// Optional tiny preview shown in each location's tooltip — same
|
||||
// missing-asset-is-fine convention as the character portraits. Path is
|
||||
// tried once per marker; a 404 just means the tooltip falls back to text.
|
||||
const THUMB = (id: string): string => `/img/locations/${id}.png`;
|
||||
|
||||
type LeafletMap = ReturnType<typeof L.map>;
|
||||
type LeafletMarker = ReturnType<typeof L.circleMarker>;
|
||||
|
||||
@@ -54,7 +61,14 @@ export function initMap(containerId: string, onClick: (id: string) => void): voi
|
||||
fillColor: '#1a1a2e',
|
||||
fillOpacity: 0.3,
|
||||
weight: 1,
|
||||
}).addTo(map).bindTooltip(loc.label, { className: 'map-tooltip', permanent: false });
|
||||
}).addTo(map).bindTooltip(
|
||||
`<div class="map-tooltip-inner">
|
||||
<img class="map-tooltip-thumb" src="${THUMB(loc.id)}" alt=""
|
||||
onerror="this.remove()">
|
||||
<span>${loc.label}</span>
|
||||
</div>`,
|
||||
{ className: 'map-tooltip', permanent: false },
|
||||
);
|
||||
m.on('click', () => onClick(loc.id));
|
||||
markers.set(loc.id, m);
|
||||
}
|
||||
@@ -71,6 +85,8 @@ export function updateMap(dread: number, areas: { rosengard: boolean; varnhem: b
|
||||
ribersborg: Math.min(2, Math.floor(dread / 45)),
|
||||
davidshall: Math.min(1, Math.floor(dread / 70)),
|
||||
stortorget: Math.min(2, Math.floor(dread / 55)),
|
||||
mollan: Math.min(2, Math.floor(dread / 48)),
|
||||
universitet: Math.min(2, Math.floor(dread / 65)),
|
||||
};
|
||||
|
||||
for (const [id, marker] of markers) {
|
||||
|
||||
@@ -161,12 +161,26 @@ body {
|
||||
color: var(--text) !important;
|
||||
font-family: inherit !important;
|
||||
font-size: 11px !important;
|
||||
padding: 3px 8px !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;
|
||||
@@ -587,6 +601,69 @@ body {
|
||||
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-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); }
|
||||
|
||||
44
src/ui.ts
44
src/ui.ts
@@ -84,7 +84,11 @@ export function initUI(
|
||||
<button id="characters-close" class="help-close">✕</button>
|
||||
</div>
|
||||
<div class="help-modal-body">
|
||||
<p class="help-flavor" style="margin-bottom:14px;">De som suttit vid bordet. Vissa längre än andra.</p>
|
||||
<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 suttit vid bordet. Vissa längre än andra.</p>
|
||||
<div id="characters-grid" class="characters-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,6 +186,12 @@ export function initUI(
|
||||
</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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
|
||||
@@ -212,6 +222,35 @@ export function initUI(
|
||||
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 suttit vid bordet. 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')!;
|
||||
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 ?? '';
|
||||
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'); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lightbox.classList.remove('visible'); });
|
||||
|
||||
// Split the map container: map on top, log below
|
||||
const mapContainer = document.getElementById('map-container')!;
|
||||
mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;';
|
||||
@@ -228,6 +267,7 @@ export function initUI(
|
||||
}
|
||||
|
||||
let latestState: State | null = null;
|
||||
let archiveMode = false;
|
||||
|
||||
export function renderState(state: State): void {
|
||||
latestState = state;
|
||||
@@ -421,7 +461,7 @@ 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 = !c.unlockEventId || met.has(c.unlockEventId);
|
||||
const known = archiveMode || !c.unlockEventId || met.has(c.unlockEventId);
|
||||
if (!known) {
|
||||
return `
|
||||
<div class="character-card locked">
|
||||
|
||||
Reference in New Issue
Block a user