Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish, flavor text, full changelog sourced from src/data/version.ts - src/data/version.ts: versioned changelog entries, in-universe Malmö voice - Map zone clicks: each location on the Leaflet map is now clickable. Unlocked cult areas give resources + log entries. Locked locations show lore hints. 30s cooldown per location so it's a decision, not a spam button. - Dockerfile + nginx.conf + docker-compose.yml (port 5684) - Gitea CI workflow (identical to 2048 — derives image from repo path, needs TKNTKN secret, pushes :latest and :sha tags) - README.md with mechanics summary and docker instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
35
src/data/version.ts
Normal file
35
src/data/version.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const VERSION = '0.2.0';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.2.0',
|
||||
date: '2026-07-07',
|
||||
changes: [
|
||||
'Klicka på platser på kartan för att besöka dem — varje besök lämnar spår',
|
||||
'Välkomnar Anders Fager, Kult och Call of Cthulhu till händelsepoolen',
|
||||
'Utökad händelsepool: ICA-kassörskor som inte blinkar, SJ-tåg som stannar i tunnlar som inte finns, en stad på Öresunds botten med belysning tänd',
|
||||
'Ny uppgradering: Paktens Förnyelse (någon tecknade den redan — vi förnyar den bara)',
|
||||
'Uppgraderingsbeskrivningar omskrivna till rätt register: vardagssvenska, kosmisk fasa, inga utropstecken',
|
||||
'Autospar var 10:e sekund och vid varje köp, versionsnyckeln djupet_v001',
|
||||
'Logg: senaste händelse överst',
|
||||
'Knappklick fungerar nu (renderingen bytte ut DOM 30 gånger per sekund)',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.1.0',
|
||||
date: '2026-07-05',
|
||||
changes: [
|
||||
'Källarmötet grundades i Rosengård',
|
||||
'Kartan över Malmö aktiverades — Turning Torso, Värnhemstorget, Øresundsbron',
|
||||
'Ritualsystemet implementerat: andakt, dread, hemlighet, kaos',
|
||||
'Göran från Discord kom med av misstag och är fortfarande kvar',
|
||||
'Den Sovande är ännu inte vaken',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -152,6 +152,85 @@ export function tick(state: State): State {
|
||||
return s;
|
||||
}
|
||||
|
||||
interface LocationVisit {
|
||||
text: string;
|
||||
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
|
||||
requiresArea?: keyof State['areas'];
|
||||
lockedText: string;
|
||||
}
|
||||
|
||||
const LOCATION_VISITS: Record<string, LocationVisit> = {
|
||||
rosengard: {
|
||||
text: 'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
|
||||
effect: { andakt: 15 },
|
||||
requiresArea: 'rosengard',
|
||||
lockedText: 'En fuktig källarlokal i Rosengård. Du kan se ljuset under dörren. Ni är inte redo än.',
|
||||
},
|
||||
varnhem: {
|
||||
text: 'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
|
||||
effect: { dread: 8, ritual: 2 },
|
||||
requiresArea: 'varnhem',
|
||||
lockedText: 'Värnhemstorget ser normalt ut. Det är ett dåligt tecken.',
|
||||
},
|
||||
torso: {
|
||||
text: 'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
|
||||
effect: { dread: 15, ritual: 5 },
|
||||
requiresArea: 'torso',
|
||||
lockedText: 'Turning Torso vrider sig i vinden. Inte i vinden. I något annat.',
|
||||
},
|
||||
oresund: {
|
||||
text: 'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
|
||||
effect: { dread: 5 },
|
||||
lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.',
|
||||
},
|
||||
ribersborg: {
|
||||
text: 'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
|
||||
effect: { andakt: 8 },
|
||||
lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.',
|
||||
},
|
||||
davidshall: {
|
||||
text: 'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
|
||||
effect: { andakt: 10 },
|
||||
lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.',
|
||||
},
|
||||
stortorget: {
|
||||
text: 'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
|
||||
effect: { kaos: 8 },
|
||||
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
|
||||
},
|
||||
};
|
||||
|
||||
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) };
|
||||
|
||||
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
|
||||
const text = areaLocked ? visit.lockedText : visit.text;
|
||||
|
||||
if (!areaLocked) {
|
||||
if (visit.effect.andakt) s.andakt += visit.effect.andakt;
|
||||
if (visit.effect.dread) s.dread += visit.effect.dread;
|
||||
if (visit.effect.kaos) s.kaos = Math.min(100, s.kaos + visit.effect.kaos);
|
||||
if (visit.effect.ritual && s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + visit.effect.ritual);
|
||||
if (visit.effect.hemlighet) s.hemlighet = Math.max(0, Math.min(100, s.hemlighet + visit.effect.hemlighet));
|
||||
}
|
||||
|
||||
s.log.unshift({ tick: now, text: `⬡ ${text}`, kind: 'system' });
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
|
||||
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 = {
|
||||
|
||||
11
src/main.ts
11
src/main.ts
@@ -1,9 +1,10 @@
|
||||
import './style.css';
|
||||
import { createState, tick, buyUpgrade, saveState, loadState, clearSave } from './engine.ts';
|
||||
import { createState, tick, buyUpgrade, visitLocation, saveState, loadState, clearSave } from './engine.ts';
|
||||
import { initUI, renderState } from './ui.ts';
|
||||
import { initMap, updateMap } from './map.ts';
|
||||
|
||||
let state = loadState() ?? createState();
|
||||
let visitCooldowns = new Map<string, number>();
|
||||
let lastTime = 0;
|
||||
const TARGET_FPS = 30;
|
||||
const FRAME_MS = 1000 / TARGET_FPS;
|
||||
@@ -18,12 +19,18 @@ function start(): void {
|
||||
() => {
|
||||
clearSave();
|
||||
state = createState();
|
||||
visitCooldowns = new Map();
|
||||
document.getElementById('overlay')!.classList.remove('visible');
|
||||
renderState(state);
|
||||
}
|
||||
);
|
||||
|
||||
initMap('map');
|
||||
initMap('map', (locationId) => {
|
||||
const result = visitLocation(state, locationId, visitCooldowns);
|
||||
state = result.state;
|
||||
visitCooldowns = result.cooldowns;
|
||||
renderState(state);
|
||||
});
|
||||
renderState(state);
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
@@ -34,7 +34,7 @@ function dreadRadius(level: number): number {
|
||||
return [6, 10, 16, 24][Math.min(level, 3)];
|
||||
}
|
||||
|
||||
export function initMap(containerId: string): void {
|
||||
export function initMap(containerId: string, onClick: (id: string) => void): void {
|
||||
if (!L) return;
|
||||
map = L.map(containerId, {
|
||||
center: [55.597, 13.002],
|
||||
@@ -55,6 +55,7 @@ export function initMap(containerId: string): void {
|
||||
fillOpacity: 0.3,
|
||||
weight: 1,
|
||||
}).addTo(map).bindTooltip(loc.label, { className: 'map-tooltip', permanent: false });
|
||||
m.on('click', () => onClick(loc.id));
|
||||
markers.set(loc.id, m);
|
||||
}
|
||||
}
|
||||
|
||||
122
src/style.css
122
src/style.css
@@ -269,6 +269,128 @@ body {
|
||||
.phase-tag.mid { color: var(--accent); border-color: var(--accent); }
|
||||
.phase-tag.late { color: var(--dread); border-color: var(--dread); }
|
||||
|
||||
/* ── Help button ── */
|
||||
.help-btn {
|
||||
margin-left: auto;
|
||||
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); }
|
||||
|
||||
/* ── Help modal ── */
|
||||
.help-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 6, 14, 0.88);
|
||||
z-index: 200;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#app { grid-template-columns: 1fr; }
|
||||
.panel { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
|
||||
49
src/ui.ts
49
src/ui.ts
@@ -1,11 +1,53 @@
|
||||
import type { State } from './engine.ts';
|
||||
import { UPGRADES } from './upgrades.ts';
|
||||
import { VERSION, CHANGELOG } from './data/version.ts';
|
||||
|
||||
export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRestart: () => void): void {
|
||||
app.innerHTML = `
|
||||
<div id="header">
|
||||
<div class="brand">DJUPET</div>
|
||||
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span></div>
|
||||
<button id="help-btn" class="help-btn" title="Hjälp & Ändringslogg">? v${VERSION}</button>
|
||||
</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">DJUPET — 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>Andakt</strong> ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.</li>
|
||||
<li><strong>Dread</strong> ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.</li>
|
||||
<li><strong>Hemlighet</strong> sjunker när ni blir för uppenbara. Når den noll är det slut.</li>
|
||||
<li><strong>Kaos</strong> byggs upp naturligt och utlöser kaotiska händelser när det når 100.</li>
|
||||
<li><strong>Ritual</strong> låses upp via Skriften. Nå 100 för att fullborda ceremonin.</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 dåliga. En del är värre.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="help-section">
|
||||
<div class="help-section-title">Om spelet</div>
|
||||
<p class="help-flavor">
|
||||
Djupet är ett idle-spel om en kult i Malmö, Sverige.<br>
|
||||
Inspirerat av Anders Fager, Kult och Call of Cthulhu.<br>
|
||||
Den Sovande är inte vaken ä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 class="panel" id="panel-left">
|
||||
@@ -77,6 +119,13 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
|
||||
|
||||
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
|
||||
|
||||
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 === '?' || (e.key === 'Escape' && helpModal.classList.contains('visible'))) helpModal.classList.toggle('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%;';
|
||||
|
||||
Reference in New Issue
Block a user