From 5fcb3394087d2f034ffe2bea9e19292248a680d1 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Tue, 7 Jul 2026 23:31:37 +0200 Subject: [PATCH] Add help modal, changelog, map visits, Dockerfile, CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/build.yml | 57 +++++++++++++++++ Dockerfile | 11 ++++ README.md | 40 ++++++++++++ docker-compose.yml | 6 ++ nginx.conf | 10 +++ src/data/version.ts | 35 +++++++++++ src/engine.ts | 79 ++++++++++++++++++++++++ src/main.ts | 11 +++- src/map.ts | 3 +- src/style.css | 122 +++++++++++++++++++++++++++++++++++++ src/ui.ts | 49 +++++++++++++++ 11 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/build.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 src/data/version.ts diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..3906697 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,57 @@ +name: Docker + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive image name and tags + id: meta + shell: bash + run: | + set -euo pipefail + + SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}" + REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}" + SHA="${GITHUB_SHA:-${GITEA_SHA:-}}" + + if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then + echo "Missing SERVER_URL/REPO/SHA env vars." >&2 + echo "SERVER_URL='${SERVER_URL}' REPO='${REPO}' SHA='${SHA}'" >&2 + exit 1 + fi + + HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||') + IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]') + SHORT_SHA=$(echo "${SHA}" | cut -c1-7) + + echo "host=${HOST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Log in to Gitea container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.host }} + username: ${{ github.actor }} + password: ${{ secrets.TKNTKN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:latest + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0331674 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..de35e5d --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Djupet + +Lovecraftian idle cult management game set in Malmö, Sweden. + +You run a small cult in a basement in Rosengård. The goal is to complete the ritual before the investigators close in. The Turning Torso is involved. The ducks at Ribersborg know more than they let on. + +Inspired by Anders Fager, Kult, and Call of Cthulhu. + +**Play:** [djupet.games.goonk.se](https://djupet.games.goonk.se) + +## Stack + +Vanilla TypeScript + Vite. No framework. Leaflet for the map. + +## Dev + +``` +npm install +npm run dev # localhost:5681 +npm run typecheck +npm run build +``` + +## Mechanics + +- **Andakt** — passive income, spend on upgrades +- **Dread** — builds as the ritual deepens, unlocks areas and events +- **Hemlighet** — your exposure level; reaches zero and you lose +- **Kaos** — triggers chaotic events at 100, resets, repeat +- **Ritual** — reach 100 to win; unlocked via the Manuscript + +Click locations on the Malmö map to visit them. There is a cooldown. + +## Docker + +``` +docker compose up -d +``` + +Serves on port `5684` by default. Override via `HOST_PORT` in `.env`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f602513 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + app: + image: ${IMAGE:-djupet:local} + restart: unless-stopped + ports: + - "${HOST_PORT:-5684}:80" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..a676565 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + location / { + try_files $uri $uri/ /index.html; + } + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; +} diff --git a/src/data/version.ts b/src/data/version.ts new file mode 100644 index 0000000..ecf7818 --- /dev/null +++ b/src/data/version.ts @@ -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', + ], + }, +]; diff --git a/src/engine.ts b/src/engine.ts index 6223eff..8163ca3 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -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 = { + 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): { state: State; cooldowns: Map } { + 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 = { diff --git a/src/main.ts b/src/main.ts index 4dc0aed..1088ea8 100644 --- a/src/main.ts +++ b/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(); 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); diff --git a/src/map.ts b/src/map.ts index 46da11e..b3c43bd 100644 --- a/src/map.ts +++ b/src/map.ts @@ -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); } } diff --git a/src/style.css b/src/style.css index 15fc3c9..5e153f0 100644 --- a/src/style.css +++ b/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); } diff --git a/src/ui.ts b/src/ui.ts index 28e0229..9cd11c0 100644 --- a/src/ui.ts +++ b/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 = ` + +
+
+
+ DJUPET — Hjälp & Ändringslogg + +
+
+
+
Hur man spelar
+
    +
  • Andakt ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.
  • +
  • Dread ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.
  • +
  • Hemlighet sjunker när ni blir för uppenbara. Når den noll är det slut.
  • +
  • Kaos byggs upp naturligt och utlöser kaotiska händelser när det når 100.
  • +
  • Ritual låses upp via Skriften. Nå 100 för att fullborda ceremonin.
  • +
  • Klicka på platser på kartan för att besöka dem. Varje besök lämnar spår.
  • +
  • Händelser inträffar automatiskt. De flesta är dåliga. En del är värre.
  • +
+
+
+
Om spelet
+

+ Djupet är ett idle-spel om en kult i Malmö, Sverige.
+ Inspirerat av Anders Fager, Kult och Call of Cthulhu.
+ Den Sovande är inte vaken ännu. Jobba på det. +

+
+
+
Ändringslogg
+ ${CHANGELOG.map(e => ` +
+
v${e.version} ${e.date}
+
    ${e.changes.map(c => `
  • ${c}
  • `).join('')}
+
+ `).join('')} +
+
+
@@ -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%;';