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:
57
.gitea/workflows/build.yml
Normal file
57
.gitea/workflows/build.yml
Normal file
@@ -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 }}
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -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
|
||||
40
README.md
Normal file
40
README.md
Normal file
@@ -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`.
|
||||
6
docker-compose.yml
Normal file
6
docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE:-djupet:local}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-5684}:80"
|
||||
10
nginx.conf
Normal file
10
nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
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