From bf6bdf9c2da29267b3e48f98b854bd2f75e7f4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Fri, 13 Nov 2026 16:37:40 +0100 Subject: [PATCH] feat(client): Startbildschirm, Spielansicht und Ergebniskarte mit dem Brett aus Ninelands Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + PLAN.md | 23 + biome.jsonc | 10 +- package-lock.json | 13 +- package.json | 5 +- public/game.html | 45 +- public/snoo.png | Bin 25102 -> 0 bytes public/splash.html | 23 +- scripts/sync-engine.sh | 9 +- src/client/api.ts | 30 + src/client/audio.ts | 70 +++ src/client/dom.ts | 26 + src/client/game.css | 183 ++++++ src/client/game.ts | 430 +++++++++++++- src/client/result.ts | 30 + src/client/splash.css | 97 ++++ src/client/splash.ts | 118 +++- src/client/style.css | 219 ++++++++ src/client/texts.ts | 121 ++++ src/client/tsconfig.json | 1 + src/engine/app/board/BoardView.ts | 901 ++++++++++++++++++++++++++++++ src/engine/app/board/atlas.ts | 94 ++++ src/engine/app/board/board.css | 560 +++++++++++++++++++ src/engine/app/board/geometry.ts | 74 +++ src/engine/app/env.ts | 41 ++ src/engine/tsconfig.json | 10 +- src/types/ninelands-env.d.ts | 11 + 27 files changed, 3085 insertions(+), 61 deletions(-) delete mode 100644 public/snoo.png create mode 100644 src/client/api.ts create mode 100644 src/client/audio.ts create mode 100644 src/client/dom.ts create mode 100644 src/client/game.css create mode 100644 src/client/result.ts create mode 100644 src/client/splash.css create mode 100644 src/client/style.css create mode 100644 src/client/texts.ts create mode 100644 src/engine/app/board/BoardView.ts create mode 100644 src/engine/app/board/atlas.ts create mode 100644 src/engine/app/board/board.css create mode 100644 src/engine/app/board/geometry.ts create mode 100644 src/engine/app/env.ts create mode 100644 src/types/ninelands-env.d.ts diff --git a/.gitignore b/.gitignore index c512e48..0f72113 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /public/*.js* /.env /rules/ +/public/*.css* +/public/fonts/ diff --git a/PLAN.md b/PLAN.md index 348b52d..371d85b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -162,3 +162,26 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game Spiel als Entry `game` (Expanded). - **Fallstrick:** `hMGet` ist laut Doku nicht für jede App freigeschaltet → einzelne `hGet`. - Offen: Löschungen von Nutzern respektieren (Devvit-Regel) – Ergebnis und Name bei Kontolöschung entfernen. + +### 2026-11-13 – Phase 1, Client + +- **Brett aus dem Hauptspiel:** `BoardView.ts`, `atlas.ts`, `geometry.ts`, `board.css` und `env.ts` kommen per + Sync-Skript nach `src/engine/app/` (unverändert). `import.meta.env` und `__API_ORIGIN__` setzt esbuild per + `--define`; Typen dafür in `src/types/ninelands-env.d.ts`. Das Symbol `i-warn` (Warn-Badge der Vorschau) steht + direkt in `public/game.html`. +- **Startbildschirm** (`splash`, inline, `height: tall`): Name, Nummer, Datum, Mini-Karte, Gesetz, Ziele bzw. eigenes + Ergebnis, Spielen-Knopf, Streak, Top 3, „i“ mit Credits. +- **Spiel** (`game`, Expanded): Ziele-Leiste mit Sternen live, Gesetz (antippbar → Regeln), Brett, Hinweiszeile, + Figuren mit Kosten, Rückgängig (1 Schritt wie im Hauptspiel). Touch: erster Tipp zeigt Vorschau und Grund, zweiter + setzt. Nach der Burg automatisch Bauer. Ende → Abgabe → Ergebniskarte (Sterne, Gold, Länder, Platz, Streak, Top 10, + Teilen über `showShareSheet`, „Play again“ als Übung, „Done“ schließt den Expanded Mode). +- Gäste spielen ohne Wertung (Hinweis + `showLoginPrompt`); wer schon gespielt hat, sieht „Practice“. +- **Layout-Fallstrick:** Brett und Hinweis liegen in einem Container mit `container-type: size`; das Brett ist + `min(100cqw, 100cqh − 3.2em)` breit. Mit `flex: 1` + `aspect-ratio` allein blieb viel Leerraum zwischen Brett und + Hinweis. +- Schrift Pixelify Sans über `@fontsource/pixelify-sans` (OFL), von esbuild nach `public/fonts/` kopiert – externe + Schrift-Abrufe sind im Devvit-Client gesperrt. +- Musik: Start beim ersten Tippen, Stumm-Knopf, Pause bei `visibilitychange`. Stumm-Schalter vorerst in + `localStorage` (geht bei App-Updates verloren – vertretbar; Redis erst, wenn es stört). +- Lokale Vorschau ohne Reddit: kleine Server-Attrappe + Playwright-Screenshots (Skripte im Scratchpad, nicht im Repo). +- Biome ergänzt bei `--write` Dateiendungen an Engine-Importen (`'../engine/core/Game.ts'`) – ist so gewollt. diff --git a/biome.jsonc b/biome.jsonc index fda6dc9..929e57f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -2,7 +2,15 @@ "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json", "css": {"formatter": {"enabled": true, "quoteStyle": "single"}}, // src/engine/ ist eine Kopie aus Ninelands (scripts/sync-engine.sh) und wird nicht umformatiert - "files": {"includes": ["**", "!dist/", "!public/*.js*", "!src/engine"]}, + "files": { + "includes": [ + "**", + "!dist/", + "!public/*.js*", + "!public/*.css*", + "!src/engine" + ] + }, "formatter": { "bracketSpacing": false, "enabled": true, diff --git a/package-lock.json b/package-lock.json index 0e2fc01..11b814b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,11 @@ "": { "name": "ninelands-game", "version": "0.0.0", - "license": "BSD-3-Clause", + "license": "GPL-3.0-only", "devDependencies": { "@biomejs/biome": "2.6.6", "@devvit/web": "0.15.3", + "@fontsource/pixelify-sans": "5.3.0", "@types/node": "24.19.2", "devvit": "0.15.3", "esbuild": "0.28.2", @@ -1673,6 +1674,16 @@ "node": ">=18" } }, + "node_modules/@fontsource/pixelify-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/pixelify-sans/-/pixelify-sans-5.3.0.tgz", + "integrity": "sha512-4RCe9nXbi7wUu5+H0FNX1sgPZ2kJ1MmY1uTkEfvQIzzqhoS8zGKJaJfdK8oreE0OU+5gM+NdurFi3H3FJRBvyw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@improbable-eng/grpc-web": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@improbable-eng/grpc-web/-/grpc-web-0.15.0.tgz", diff --git a/package.json b/package.json index 4c82f9a..599e060 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "type": "module", "scripts": { "build": "npm run build:client -- --metafile=dist/client.meta.json --minify && npm run build:server -- --metafile=dist/server.meta.json --minify", - "build:client": "esbuild --bundle --log-level=warning --sourcemap=linked --target=es2023 --format=esm --outdir=public --platform=browser src/client/splash.ts src/client/game.ts", + "build:client": "esbuild --bundle --log-level=warning --sourcemap=linked --target=es2023 --format=esm --outdir=public --platform=browser --define:import.meta.env.MODE='\"reddit\"' --define:import.meta.env.BASE_URL='\"/\"' --define:__API_ORIGIN__='\"\"' --loader:.woff2=file --loader:.woff=file --asset-names=fonts/[name]-[hash] src/client/splash.ts src/client/game.ts", "build:server": "esbuild --bundle --log-level=warning --sourcemap=linked --target=es2023 --format=cjs --outdir=dist/server --platform=node src/server/index.ts", - "clean": "rm -rf dist/ public/*.js*", + "clean": "rm -rf dist/ public/*.js* public/*.css* public/fonts/", "format": "npm run lint -- --fix", "lint": "biome check --error-on-warnings", "playtest": "devvit playtest", @@ -21,6 +21,7 @@ "devDependencies": { "@biomejs/biome": "2.6.6", "@devvit/web": "0.15.3", + "@fontsource/pixelify-sans": "5.3.0", "@types/node": "24.19.2", "devvit": "0.15.3", "esbuild": "0.28.2", diff --git a/public/game.html b/public/game.html index 76d9cbd..fcbe7e1 100644 --- a/public/game.html +++ b/public/game.html @@ -2,36 +2,27 @@ - - Game + + Ninelands + - -
-
- - 0 - -
-
+ + +
diff --git a/public/snoo.png b/public/snoo.png deleted file mode 100644 index 5d9055677ae6cd593c23b47c739cbc1e9471d21a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25102 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 W3;q14tjII*fA=_y4Z7CUi}-(4tpCLT diff --git a/public/splash.html b/public/splash.html index 669b7b6..933fa12 100644 --- a/public/splash.html +++ b/public/splash.html @@ -3,29 +3,12 @@ - Splash + Ninelands + - -
- Snoo - -
+
diff --git a/scripts/sync-engine.sh b/scripts/sync-engine.sh index e983d57..6153db1 100755 --- a/scripts/sync-engine.sh +++ b/scripts/sync-engine.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Holt die Engine (src/core + die JSONs, die sie importiert) von einem festen Ninelands-Tag nach src/engine/, +# Holt die Engine (src/core + die JSONs, die sie importiert, dazu die Brett-Darstellung src/app/board + env.ts) von einem festen Ninelands-Tag nach src/engine/, # dazu Sprites (Tiles, Brett-Themen) und ausgewählte Musik nach public/. # Die Kopie wird nie von Hand geändert – Änderungen gehören ins Hauptspiel, dann hier neu synchronisieren. # Aufruf: scripts/sync-engine.sh [pfad-zum-hivedoku-repo] @@ -18,13 +18,14 @@ trap 'rm -rf "$tmp"' EXIT musik=(peaceful-village) musik_pfade=() for titel in "${musik[@]}"; do musik_pfade+=("public/audio/music/$titel.webm"); done -git -C "$repo" archive "$commit" src/core src/campaign src/puzzles public/sprites/tiles public/sprites/themes \ +git -C "$repo" archive "$commit" src/core src/campaign src/puzzles src/app/env.ts src/app/board/BoardView.ts \ + src/app/board/atlas.ts src/app/board/geometry.ts src/app/board/board.css public/sprites/tiles public/sprites/themes \ "${musik_pfade[@]}" | tar -x -C "$tmp" # Alte Kopie ersetzen, eigene Dateien (tsconfig.json, README.md) behalten -rm -rf "$ziel/core" "$ziel/campaign" "$ziel/puzzles" +rm -rf "$ziel/core" "$ziel/campaign" "$ziel/puzzles" "$ziel/app" mkdir -p "$ziel" -cp -r "$tmp/src/core" "$tmp/src/campaign" "$tmp/src/puzzles" "$ziel/" +cp -r "$tmp/src/core" "$tmp/src/campaign" "$tmp/src/puzzles" "$tmp/src/app" "$ziel/" find "$ziel" -name '*.test.ts' -delete # Assets (Nachweise in CREDITS.md) diff --git a/src/client/api.ts b/src/client/api.ts new file mode 100644 index 0000000..f7ebdcf --- /dev/null +++ b/src/client/api.ts @@ -0,0 +1,30 @@ +// Aufrufe an den eigenen Devvit-Server (gleiche Herkunft; externe Abrufe sind im Client gesperrt). +import { + Endpoint, + type ErrorRsp, + type GetDayRsp, + type SubmitReq, + type SubmitRsp, +} from '../shared/api.ts' + +async function call(endpoint: Endpoint, body?: unknown): Promise { + const rsp = await fetch(endpoint, { + method: body === undefined ? 'GET' : 'POST', + headers: { + Accept: 'application/json', + ...(body === undefined ? {} : {'Content-Type': 'application/json'}), + }, + ...(body === undefined ? {} : {body: JSON.stringify(body)}), + }) + if (!rsp.ok) { + const err = (await rsp.json().catch(() => null)) as ErrorRsp | null + throw new Error(err?.error ?? `HTTP ${rsp.status}`) + } + return (await rsp.json()) as T +} + +export const fetchDay = (): Promise => + call(Endpoint.GetDay) + +export const submitGame = (req: SubmitReq): Promise => + call(Endpoint.Submit, req) diff --git a/src/client/audio.ts b/src/client/audio.ts new file mode 100644 index 0000000..c8c335b --- /dev/null +++ b/src/client/audio.ts @@ -0,0 +1,70 @@ +// Hintergrundmusik nach den Devvit-Regeln: erst nach einer Nutzeraktion, Stumm-Knopf, stumm beim Wegscrollen. +// Der Stumm-Schalter liegt in localStorage – Devvit leert das bei App-Updates, dann ist die Musik wieder an. +const KEY = 'ninelands-muted' +const audio = new Audio('/audio/music/peaceful-village.mp3') +audio.loop = true +audio.volume = 0.35 + +let muted = readMuted() +let started = false +let listening = false + +function readMuted(): boolean { + try { + return localStorage.getItem(KEY) === '1' + } catch { + return false + } +} + +function saveMuted(): void { + try { + localStorage.setItem(KEY, muted ? '1' : '0') + } catch { + // privates Fenster o. Ä.: dann eben nicht merken + } +} + +function play(): void { + if (muted || document.hidden) return + audio.play().catch(() => { + // Autoplay verweigert – der nächste Tipp versucht es erneut + started = false + }) +} + +/** Musik an den Stumm-Knopf binden; startet beim ersten Tipp irgendwo im Spiel */ +export function initMusic(button: HTMLButtonElement): void { + const label = () => { + button.textContent = muted ? '🔇' : '♪' + button.title = muted ? 'Music off' : 'Music on' + } + label() + button.addEventListener('click', ev => { + ev.stopPropagation() + muted = !muted + saveMuted() + label() + if (muted) audio.pause() + else { + started = true + play() + } + }) + if (!listening) { + listening = true + document.addEventListener( + 'pointerup', + () => { + if (started) return + started = true + play() + }, + {capture: true}, + ) + document.addEventListener('visibilitychange', () => { + if (document.hidden) audio.pause() + else if (started) play() + }) + } +} diff --git a/src/client/dom.ts b/src/client/dom.ts new file mode 100644 index 0000000..1a5df07 --- /dev/null +++ b/src/client/dom.ts @@ -0,0 +1,26 @@ +// Kleine DOM-Helfer ohne Framework. + +type Child = Node | string | null | undefined | false + +/** Element mit Klassen und Kindern: h('div.card.big', 'Text', h('b', '1')) */ +export function h( + tagAndClasses: K | `${K}.${string}`, + ...children: Child[] +): HTMLElementTagNameMap[K] { + const [tag, ...classes] = tagAndClasses.split('.') as [K, ...string[]] + const el = document.createElement(tag) + if (classes.length) el.className = classes.join(' ') + for (const c of children) if (c) el.append(c) + return el +} + +export function byId(id: string): T { + const el = document.getElementById(id) + if (!el) throw new Error(`#${id} fehlt`) + return el as T +} + +/** Kinder ohne Lücken (für replaceChildren) */ +export function nodes(...children: Child[]): (Node | string)[] { + return children.filter((c): c is Node | string => Boolean(c)) +} diff --git a/src/client/game.css b/src/client/game.css new file mode 100644 index 0000000..c9aeee0 --- /dev/null +++ b/src/client/game.css @@ -0,0 +1,183 @@ +/* Spielansicht: oben Ziele, in der Mitte das Brett, unten Figuren – passt hochkant auf ein Handy. */ +#app { + height: 100%; + max-width: 34rem; + margin: 0 auto; + padding: 0.5rem 0.6rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.top { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.top .title { + font-size: 1.2rem; + color: var(--px-gold); +} + +.top .spacer { + flex: 1; +} + +.goals { + display: flex; + gap: 0.4rem; + justify-content: space-between; + font-size: 0.95rem; +} + +.goal { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.15rem; + padding: 0.2rem 0.3rem; + background: var(--px-wood-dark); + border: 2px solid var(--px-ink); + white-space: nowrap; +} + +.goal-star { + color: var(--px-gold); + font-family: var(--font-pixel); + margin-right: 0.2rem; +} + +.law { + font-family: var(--font-text); + font-size: 0.8rem; + text-align: left; + background: var(--px-wood-dark); + color: var(--px-parchment-dim); + border-width: 2px; + box-shadow: none; + padding: 0.25rem 0.5rem; +} + +.law b { + color: var(--px-parchment); +} + +.practice { + margin: 0; + font-size: 0.8rem; + text-align: center; + color: var(--px-gold); +} + +/* Brett und Hinweis gemeinsam mittig im freien Raum; das Brett so groß wie möglich, aber quadratisch */ +.board-area { + flex: 1 1 auto; + min-height: 0; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.3rem; +} + +#app .board-host { + /* 3.2em = Platz für die Hinweiszeile darunter */ + width: min(100cqw, 100cqh - 3.2em); + height: auto; + flex: none; +} + +.hint { + margin: 0; + min-height: 2.6em; + font-size: 0.85rem; + text-align: center; + width: 100%; +} + +.hint.bad { + color: #ff9a8f; +} + +.hint.good { + color: var(--px-green); +} + +.hint.warn { + color: var(--px-gold); +} + +.toolbar { + display: flex; + gap: 0.35rem; + align-items: stretch; +} + +.tools { + flex: 1; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.35rem; +} + +.tool { + display: grid; + justify-items: center; + gap: 0.05rem; + padding: 0.25rem 0.1rem; + font-size: 0.75rem; + background: var(--px-parchment-dim); +} + +.tool.active { + background: var(--px-gold); + outline: 2px solid var(--px-parchment); +} + +.tool-sprite { + width: 32px; + height: 32px; + image-rendering: pixelated; +} + +.tool-cost { + font-size: 0.7rem; + opacity: 0.8; +} + +.toolbar > button.icon { + font-size: 1.4rem; +} + +/* Ergebnis */ +.result { + text-align: center; +} + +.result .stars.big { + font-size: 2.6rem; + margin: 0; + line-height: 1; +} + +.result-nums { + font-size: 1.15rem; +} + +.rank { + font-size: 1.1rem; + color: var(--px-parchment); +} + +.small { + font-size: 0.8rem; +} + +.rules ul { + margin: 0; + padding-left: 1.2rem; + font-size: 0.9rem; +} diff --git a/src/client/game.ts b/src/client/game.ts index e37f168..a33cb1e 100644 --- a/src/client/game.ts +++ b/src/client/game.ts @@ -1,2 +1,428 @@ -// Platzhalter – das Spiel folgt im nächsten Schritt von Phase 1 -export {} +// Spielansicht (Expanded Mode): Tageskarte spielen, am Ende abgeben und die Ergebniskarte zeigen. +import './style.css' +import './game.css' +import { + exitExpandedMode, + showLoginPrompt, + showShareSheet, +} from '@devvit/web/client' +import {spriteUrl} from '../engine/app/board/atlas.ts' +import { + BoardView, + type PlacementPreviewView, + pieceSpriteName, +} from '../engine/app/board/BoardView.ts' +import {Game} from '../engine/core/Game.ts' +import {starsOf} from '../engine/core/goals.ts' +import {encodeMoves} from '../engine/core/replay.ts' +import {UNIT_TYPES, type UnitType} from '../engine/core/units.ts' +import type {WorldTrait} from '../engine/core/WorldRules.ts' +import type {EncodedMove, GetDayRsp, SubmitRsp} from '../shared/api.ts' +import {dailyBoard, dailyMap, dayLabel} from '../shared/daily.ts' +import {fetchDay, submitGame} from './api.ts' +import {initMusic} from './audio.ts' +import {byId, h, nodes} from './dom.ts' +import {leaderTable, shareLine} from './result.ts' +import { + HINTS, + lawName, + lawText, + placementError, + starsLine, + UNIT_DESC, + UNIT_NAME, + ZONE_NAMES, +} from './texts.ts' + +const app = byId('app') + +let day: GetDayRsp +let game: Game +let tool: UnitType = 'HIVEMOTHER' +/** Touch: erster Tipp zeigt die Vorschau, zweiter Tipp auf dasselbe Feld setzt */ +let pendingTouch: number | null = null +let hovered: number | null = null +let finished = false + +const view = new BoardView({interactive: true}) +const hint = h('p.hint') +const goldEl = h('span.pixel') +const landsEl = h('span.pixel') +const goldStar = h('span.goal-star') +const landsStar = h('span.goal-star') +const bothStar = h('span.goal-star') +const undoBtn = h('button.icon', '↶') +const toolButtons = new Map() + +view.listen({click: onCellClick, hover: onHover}) +undoBtn.addEventListener('click', undo) + +async function main(): Promise { + app.replaceChildren(h('div.loading', 'Loading the nine lands…')) + try { + day = await fetchDay() + } catch (err) { + app.replaceChildren( + h('div.error', `Could not load today's map. ${(err as Error).message}`), + ) + return + } + start() +} + +function start(): void { + const map = dailyMap(new Date(`${day.day}T00:00:00Z`)) + game = new Game(dailyBoard(map), {undoLimit: 1}) + tool = 'HIVEMOTHER' + finished = false + view.setBoard(game.board) + view.setLandNames(ZONE_NAMES) + render() +} + +function render(): void { + const practice = day.mine !== null || !day.loggedIn + const mute = h('button.icon', '♪') + initMusic(mute) + const help = h('button.icon', '?') + help.addEventListener('click', showRules) + + const law = h( + 'button.law', + h('b', lawName(game.trait as WorldTrait)), + ' · ', + lawText(game.trait as WorldTrait), + ) + law.addEventListener('click', showRules) + + const tools = h('div.tools') + for (const unit of UNIT_TYPES) { + const cost = game.cost(unit) + const btn = h( + 'button.tool', + spriteImg(unit), + h('span.tool-name', UNIT_NAME[unit]), + h('span.tool-cost', cost > 0 ? `${cost}g` : 'free'), + ) + btn.title = UNIT_DESC[unit] + btn.addEventListener('click', () => selectTool(unit)) + toolButtons.set(unit, btn) + tools.append(btn) + } + + const host = h('div.board-host') + host.append(view.svg) + + app.replaceChildren( + ...nodes( + h( + 'header.top', + h('span.pixel.title', `Ninelands #${day.number}`), + h('span.muted.date', dayLabel(day.day)), + h('span.spacer'), + mute, + help, + ), + h( + 'div.goals', + h( + 'span.goal', + goldStar, + '🪙 ', + goldEl, + h('span.muted', ` / ${day.goals.gold}`), + ), + h( + 'span.goal', + landsStar, + '🏰 ', + landsEl, + h('span.muted', ` / ${day.goals.lands} lands`), + ), + h('span.goal', bothStar, h('span.muted', 'both')), + ), + practice + ? h( + 'p.practice', + day.loggedIn + ? 'Practice – only your first game counts.' + : 'Playing as guest – log in to join the leaderboard.', + ) + : null, + law, + h('div.board-area', host, hint), + h('div.toolbar', tools, undoBtn), + ), + ) + update() +} + +function spriteImg(unit: UnitType): HTMLImageElement { + const img = h('img.tool-sprite') + img.src = spriteUrl(pieceSpriteName(unit, 'red', 'kingdom')) + img.alt = '' + return img +} + +function selectTool(unit: UnitType): void { + tool = unit + pendingTouch = null + update() +} + +function onHover(cell: number | null): void { + hovered = cell + renderPreview() + renderHint() +} + +function onCellClick(cell: number, touch: boolean): void { + if (finished) return + if (touch && pendingTouch !== cell) { + pendingTouch = cell + renderPreview() + renderHint() + return + } + place(cell) +} + +function place(cell: number): void { + const result = game.place(tool, cell) + pendingTouch = null + if (!result.ok) { + setHint(placementError(tool, result.error, result.cost, game.score), 'bad') + return + } + const net = result.yield.points - result.cost + if (net !== 0) + view.floatLabel( + cell, + `${net > 0 ? '+' : '−'}${Math.abs(net)}`, + net > 0 ? 'gain' : 'loss', + ) + // Nach der Burg gleich mit Bauern weiter (wie im Hauptspiel) + if (tool === 'HIVEMOTHER') tool = 'HIVEWORKER' + update() + if (game.isGameOver()) void finish() +} + +function undo(): void { + if (finished || !game.canUndo) return + const move = game.undo() + if (move?.unit === 'HIVEMOTHER') tool = 'HIVEMOTHER' + update() +} + +function update(): void { + view.update(game) + view.setRulers(game.rulers()) + view.setLegal(finished ? null : game.legalMoves(tool)) + const stars = starsOf(game, day.goals) + goldEl.textContent = String(game.score) + landsEl.textContent = String(game.regionCounts()[0] ?? 0) + goldStar.textContent = stars.gold ? '★' : '☆' + landsStar.textContent = stars.lands ? '★' : '☆' + bothStar.textContent = stars.both ? '★' : '☆' + undoBtn.disabled = finished || !game.canUndo + const hasCastle = game.count('HIVEMOTHER') > 0 + for (const [unit, btn] of toolButtons) { + btn.classList.toggle('active', unit === tool) + btn.disabled = + finished || + (unit === 'HIVEMOTHER' ? hasCastle : !hasCastle) || + game.legalMoveCount(unit) === 0 + } + renderPreview() + renderHint() +} + +/** Feld, für das gerade eine Vorschau gilt: angetippt (Touch) oder unter der Maus */ +function activeCell(): number | null { + return pendingTouch ?? hovered +} + +function renderPreview(): void { + const cell = finished ? null : activeCell() + view.setPlacementPreview(cell === null ? null : previewOf(cell)) + view.setPreview(cell, cell !== null && game.legalMoves(tool)[cell] === 1) +} + +function previewOf(cell: number): PlacementPreviewView | null { + const p = game.preview(tool, cell) + if (!p.ok) return null + const sign = p.net > 0 ? '+' : p.net < 0 ? '−' : '±' + return { + cell, + unit: tool, + zoi: p.zoi, + label: `${sign}${Math.abs(p.net)}`, + owner: 1, + tone: game.endsGameAfter(tool, cell) + ? 'warn' + : p.net > 0 + ? 'gain' + : p.net < 0 + ? 'loss' + : 'neutral', + } +} + +function renderHint(): void { + if (finished) return + const cell = activeCell() + if (cell !== null) { + const p = game.preview(tool, cell) + if (!p.ok) { + setHint(placementError(tool, p.error, p.cost, game.score), 'bad') + return + } + const ends = game.endsGameAfter(tool, cell) + const gain = `${UNIT_NAME[tool]} here: ${p.net >= 0 ? '+' : '−'}${Math.abs(p.net)} gold.` + setHint( + `${gain}${ends ? ' This ends the game.' : ''}${pendingTouch === cell ? ` ${HINTS.touch}` : ''}`, + ends ? 'warn' : 'good', + ) + return + } + if (game.count('HIVEMOTHER') === 0) setHint(HINTS.castle) + else if (game.moves.length < 3) setHint(HINTS.farmer) + else setHint(HINTS.general) +} + +function setHint(text: string, tone: 'good' | 'bad' | 'warn' | '' = ''): void { + hint.textContent = text + hint.className = `hint ${tone}`.trim() +} + +async function finish(): Promise { + finished = true + update() + setHint('No move left – the realm is complete!', 'good') + const moves = encodeMoves(game.moves) as EncodedMove[] + const stars = starsOf(game, day.goals).count + const local = { + stars, + gold: game.score, + lands: game.regionCounts()[0] ?? 0, + } + let rsp: SubmitRsp | null = null + if (day.loggedIn) { + try { + rsp = await submitGame({day: day.day, moves}) + day.mine = rsp.result + } catch (err) { + setHint(`Could not save your result: ${(err as Error).message}`, 'bad') + } + } + showResult(local, rsp) +} + +function showResult( + played: {stars: number; gold: number; lands: number}, + rsp: SubmitRsp | null, +): void { + const line = shareLine(day.number, played) + const share = h('button', 'Share') + share.addEventListener('click', () => { + void showShareSheet({text: line}) + }) + const again = h('button', 'Play again') + again.addEventListener('click', () => { + overlay.remove() + start() + }) + const close = h('button.primary', 'Done') + close.addEventListener('click', ev => exitExpandedMode(ev)) + + let status: Node + if (!day.loggedIn) { + const login = h('button', 'Log in') + login.addEventListener('click', () => showLoginPrompt()) + status = h( + 'div.row', + h('p.muted', 'Log in to enter the leaderboard.'), + login, + ) + } else if (rsp && !rsp.counted) { + status = h( + 'p.muted', + `Practice game. Your first game counts: ${starsLine(rsp.result.stars)} · ${rsp.result.gold} gold · #${rsp.result.rank}.`, + ) + } else if (rsp) { + status = h( + 'p.pixel.rank', + `#${rsp.result.rank} of ${rsp.players}`, + rsp.stats.streak > 0 ? ` · 🔥 ${rsp.stats.streak}-day streak` : '', + ) + } else { + status = h('p.muted', 'Your result could not be saved.') + } + + const overlay = h( + 'div.overlay', + h( + 'div.panel.result', + h( + 'h2', + played.stars === 3 ? 'Ruler of the Nine Lands!' : 'Realm complete', + ), + h('p.stars.big', starsLine(played.stars)), + h( + 'p.pixel.result-nums', + h('span.gold', `${played.gold} gold`), + ' · ', + `${played.lands}/9 lands`, + ), + status, + rsp ? leaderTable(rsp.top, rsp.result.rank) : null, + h('p.muted.small', 'Compare your realm in the comments below!'), + h('div.row.center', share, again, close), + ), + ) + document.body.append(overlay) +} + +function showRules(): void { + const close = h('button.primary', 'Got it') + const overlay = h( + 'div.overlay', + h( + 'div.panel.rules', + h('h2', 'How to play'), + h( + 'p', + 'Place pieces to earn as much gold as possible. Every piece claims land around it – nothing can be built on claimed land. The game ends when no move is left.', + ), + h( + 'ul', + ...UNIT_TYPES.map(u => + h( + 'li', + h('b', UNIT_NAME[u]), + ` (${game.cost(u) || 'free'}): ${UNIT_DESC[u]}`, + ), + ), + ), + h( + 'p', + 'Gold: ore and herbs 20, pasture 5 per claimed cell (7 with a lake). You rule a land when you claim at least half of it.', + ), + h( + 'p', + h('b', `Law of the realm – ${lawName(game.trait as WorldTrait)}: `), + lawText(game.trait as WorldTrait), + ), + h( + 'p', + h('b', 'Stars: '), + `reach ${day.goals.gold} gold, rule ${day.goals.lands} lands, or both in one game. Same map for everyone today – only your first game counts.`, + ), + h('div.row.end', close), + ), + ) + close.addEventListener('click', () => overlay.remove()) + document.body.append(overlay) +} + +void main() diff --git a/src/client/result.ts b/src/client/result.ts new file mode 100644 index 0000000..e981074 --- /dev/null +++ b/src/client/result.ts @@ -0,0 +1,30 @@ +// Bausteine für Start- und Ergebnisansicht. +import type {LeaderRow} from '../shared/api.ts' +import {h} from './dom.ts' +import {starsLine} from './texts.ts' + +/** Teilen-Zeile wie im Hauptspiel: „Ninelands #12 ★★☆ · 9/9 lands · 85 gold“ */ +export function shareLine( + number: number, + r: {stars: number; gold: number; lands: number}, +): string { + return `Ninelands #${number} ${starsLine(r.stars)} · ${r.lands}/9 lands · ${r.gold} gold` +} + +export function leaderTable(rows: LeaderRow[], myRank = 0): HTMLElement { + if (rows.length === 0) + return h('p.muted', 'No one has played yet – be the first!') + const table = h('table.leaders') + for (const r of rows) { + const tr = h( + 'tr', + h('td.rank', `${r.rank}.`), + h('td.name', r.name), + h('td.num.stars', starsLine(r.stars)), + h('td.num', `${r.gold}g`), + ) + if (r.rank === myRank) tr.className = 'me' + table.append(tr) + } + return table +} diff --git a/src/client/splash.css b/src/client/splash.css new file mode 100644 index 0000000..76a5c86 --- /dev/null +++ b/src/client/splash.css @@ -0,0 +1,97 @@ +/* Startbildschirm im Post: Kopf oben, darunter Karte und Infos – nebeneinander, auf schmalen Posts untereinander. */ +#app { + height: 100%; + padding: 0.6rem 0.8rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.head { + position: relative; + text-align: center; +} + +.head h1 { + margin: 0; + font-size: 2rem; + color: var(--px-gold); + text-shadow: 3px 3px 0 var(--px-ink); +} + +.head .sub { + margin: 0; + font-family: var(--font-pixel); +} + +.head .info { + position: absolute; + top: 0; + right: 0; + font-size: 0.9rem; + min-width: 2rem; +} + +.body { + flex: 1; + min-height: 0; + display: flex; + gap: 0.8rem; + align-items: center; + justify-content: center; +} + +.mini { + height: 100%; + max-height: 15rem; + aspect-ratio: 1 / 1; + flex: 0 1 auto; + border: 3px solid var(--px-ink); + box-shadow: var(--px-shadow); +} + +.side { + display: grid; + gap: 0.45rem; + justify-items: center; + min-width: 11rem; +} + +.side p { + margin: 0; +} + +.law { + font-size: 0.85rem; +} + +.goals-line, +.mine { + text-align: center; + font-size: 0.95rem; +} + +.streak { + color: var(--px-gold); +} + +.top { + width: 100%; + min-width: 12rem; +} + +.top-title { + font-size: 0.8rem; + color: var(--px-parchment-dim); + text-align: center; +} + +/* Schmale Posts (Handy): Karte über den Infos */ +@media (max-width: 30rem) { + .body { + flex-direction: column; + } + .mini { + max-height: 9.5rem; + } +} diff --git a/src/client/splash.ts b/src/client/splash.ts index 040435e..0501514 100644 --- a/src/client/splash.ts +++ b/src/client/splash.ts @@ -1,4 +1,118 @@ +// Startbildschirm im Post (Inline): Name, Nummer, Datum, Vorschau der Karte, eigene Werte, Spielen-Knopf. +import './style.css' +import './splash.css' import {requestExpandedMode} from '@devvit/web/client' +import {renderMiniBoard} from '../engine/app/board/BoardView.ts' +import type {WorldTrait} from '../engine/core/WorldRules.ts' +import type {GetDayRsp} from '../shared/api.ts' +import {dailyBoard, dailyMap, dayLabel} from '../shared/daily.ts' +import {fetchDay} from './api.ts' +import {byId, h} from './dom.ts' +import {leaderTable} from './result.ts' +import {lawName, starsLine} from './texts.ts' -const startBtn = document.getElementById('start-btn') as HTMLButtonElement -startBtn.addEventListener('click', ev => requestExpandedMode(ev, 'game')) +const app = byId('app') + +async function main(): Promise { + app.replaceChildren(h('div.loading', 'Loading…')) + let day: GetDayRsp + try { + day = await fetchDay() + } catch (err) { + app.replaceChildren( + h('div.error', `Could not load the map. ${(err as Error).message}`), + ) + return + } + render(day) +} + +function render(day: GetDayRsp): void { + const map = dailyMap(new Date(`${day.day}T00:00:00Z`)) + const mini = h('div.mini') + renderMiniBoard(mini, dailyBoard(map)) + + const play = h('button.primary', day.mine ? 'Play again' : 'Play') + play.addEventListener('click', ev => requestExpandedMode(ev, 'game')) + + const info = h('button.icon.info', 'i') + info.title = 'Credits' + info.addEventListener('click', showCredits) + + const mine = day.mine + const status = mine + ? h( + 'p.pixel.mine', + h('span.stars', starsLine(mine.stars)), + ` ${mine.gold} gold · #${mine.rank} of ${day.players}`, + ) + : h( + 'p.goals-line', + h('span.stars', '★'), + ` ${day.goals.gold} gold `, + h('span.stars', '★'), + ` ${day.goals.lands} lands `, + h('span.stars', '★'), + ' both', + ) + + const streak = day.stats?.streak ?? 0 + app.replaceChildren( + h( + 'header.head', + h('h1', 'Ninelands'), + h( + 'p.muted.sub', + `#${day.number} · ${dayLabel(day.day)}${day.today ? '' : ' · past map'}`, + ), + info, + ), + h( + 'main.body', + mini, + h( + 'div.side', + h('p.law', 'Law: ', h('b', lawName(day.trait as WorldTrait))), + status, + play, + streak > 0 ? h('p.pixel.streak', `🔥 ${streak}-day streak`) : null, + h( + 'div.top', + h( + 'p.pixel.top-title', + day.players > 0 + ? `Today's best · ${day.players} playing` + : "Today's best", + ), + leaderTable(day.top.slice(0, 3), mine?.rank ?? 0), + ), + ), + ), + ) +} + +function showCredits(): void { + const close = h('button.primary', 'Close') + const overlay = h( + 'div.overlay', + h( + 'div.panel', + h('h2', 'Credits'), + h( + 'p', + 'Ninelands by StackTeam – a daily port of the strategy game Ninelands.', + ), + h( + 'p', + 'Music: "Dreamscape (part I)" by Vee Ray, CC BY-SA 4.0 (re-encoded).', + ), + h('p', 'Graphics: "Mini World Sprites" by Shade and octoshrimpy, CC0.'), + h('p', 'Font: Pixelify Sans, SIL Open Font License 1.1.'), + h('div.row.end', close), + ), + ) + close.addEventListener('click', () => overlay.remove()) + document.body.append(overlay) +} + +void main() diff --git a/src/client/style.css b/src/client/style.css new file mode 100644 index 0000000..727ece5 --- /dev/null +++ b/src/client/style.css @@ -0,0 +1,219 @@ +/* + * Pixel-Look wie im Hauptspiel (pixel.css): Holz, Pergament und Tinte, keine Rundungen, harte Schatten. + * Überschriften, Zahlen und Knöpfe in Pixelify Sans, Fließtext in der Systemschrift (gut lesbar). + */ +@import '@fontsource/pixelify-sans/latin-400.css'; +@import '@fontsource/pixelify-sans/latin-700.css'; +@import '../engine/app/board/board.css'; + +:root { + --px-ink: #2b1d14; + --px-wood: #5b3a22; + --px-wood-light: #7a5233; + --px-wood-dark: #3f2716; + --px-parchment: #f4e4c1; + --px-parchment-dim: #c9b38a; + --px-gold: #f2c14e; + --px-gold-dark: #b8841c; + --px-grass: #3e5a2a; + --px-grass-dark: #2c4220; + --px-green: #9be15d; + --px-red: #e05a4f; + --player-1: #e05a4f; + --player-2: #3fc1c9; + --bandit: #6b6b6b; + --font-pixel: 'Pixelify Sans', ui-monospace, monospace; + --font-text: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --px-shadow: 3px 3px 0 rgba(0, 0, 0, 0.35); + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; + border-radius: 0; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background-color: var(--px-grass-dark); + /* feines Schachbrett wie Grasnarbe */ + background-image: conic-gradient( + rgba(0, 0, 0, 0.08) 25%, + transparent 0 50%, + rgba(0, 0, 0, 0.08) 0 75%, + transparent 0 + ); + background-size: 4px 4px; + color: var(--px-parchment); + font-family: var(--font-text); + font-size: 15px; + line-height: 1.35; + -webkit-tap-highlight-color: transparent; + overflow: hidden; +} + +h1, +h2, +.pixel { + font-family: var(--font-pixel); + font-weight: 700; + letter-spacing: 0.02em; +} + +button { + font: inherit; + font-family: var(--font-pixel); + color: var(--px-ink); + background: var(--px-parchment); + border: 3px solid var(--px-ink); + box-shadow: var(--px-shadow); + padding: 0.45em 0.9em; + cursor: pointer; + touch-action: manipulation; +} + +button:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.35); +} + +button:disabled { + opacity: 0.45; + cursor: default; + transform: none; +} + +button.primary { + background: var(--px-gold); + font-size: 1.25rem; + padding: 0.5em 1.6em; +} + +button.icon { + padding: 0.25em 0.5em; + min-width: 2.4rem; + font-size: 1.05rem; + line-height: 1.2; +} + +.panel { + background: var(--px-wood); + border: 3px solid var(--px-ink); + box-shadow: var(--px-shadow); +} + +.muted { + color: var(--px-parchment-dim); +} + +.gold { + color: var(--px-gold); +} + +.stars { + font-family: var(--font-pixel); + color: var(--px-gold); + letter-spacing: 0.08em; +} + +/* Ladeanzeige und Fehler */ +.loading, +.error { + display: grid; + place-items: center; + height: 100%; + font-family: var(--font-pixel); + text-align: center; + padding: 1rem; +} + +/* ---------- Bestenliste (Start und Ergebnis) ---------- */ +.leaders { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.leaders td { + padding: 0.15rem 0.3rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.25); +} + +.leaders td.rank { + width: 2.2em; + font-family: var(--font-pixel); + color: var(--px-parchment-dim); +} + +.leaders td.name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 9rem; +} + +.leaders td.num { + text-align: right; + font-family: var(--font-pixel); + white-space: nowrap; +} + +.leaders tr.me td { + color: var(--px-gold); +} + +/* ---------- Overlay (Regeln, Credits, Ergebnis) ---------- */ +.overlay { + position: fixed; + inset: 0; + background: rgba(20, 12, 6, 0.72); + display: grid; + place-items: center; + padding: 0.75rem; + z-index: 10; +} + +.overlay .panel { + width: min(26rem, 100%); + max-height: 100%; + overflow: auto; + padding: 1rem; + display: grid; + gap: 0.6rem; +} + +.overlay h2 { + margin: 0; + font-size: 1.4rem; + color: var(--px-gold); +} + +.overlay p { + margin: 0; +} + +.overlay a { + color: var(--px-gold); +} + +.row { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.row.end { + justify-content: flex-end; +} + +.row.center { + justify-content: center; +} diff --git a/src/client/texts.ts b/src/client/texts.ts new file mode 100644 index 0000000..1206f0c --- /dev/null +++ b/src/client/texts.ts @@ -0,0 +1,121 @@ +// Englische Texte (aus dem Hauptspiel übernommen und für die Reddit-Fassung gekürzt). +import type {PlacementError} from '../engine/core/Game.ts' +import type {UnitType} from '../engine/core/units.ts' +import { + BASE_PIECE_COSTS, + getPieceCost, + type WorldTrait, +} from '../engine/core/WorldRules.ts' + +export const UNIT_NAME: Record = { + HIVEMOTHER: 'Castle', + DRONE: 'Knight', + HIVEWORKER: 'Farmer', + HIVESCOUT: 'Bishop', +} + +export const UNIT_DESC: Record = { + HIVEMOTHER: 'Up to 5 cells in straight lines · only once', + DRONE: '2 cells straight, stays in its land · 2× yield', + HIVEWORKER: 'The 8 surrounding cells in its own land', + HIVESCOUT: '2 cells diagonally, also across borders', +} + +export const ZONE_NAMES = [ + 'Crownland', + 'Northmarch', + 'Oakgrove', + 'Millvale', + 'Ravenrock', + 'Lakeland', + 'Westwood', + 'Stonequarry', + 'Hillcountry', +] + +export function lawName(trait: WorldTrait): string { + return ( + ( + { + TANDEM_MINING: 'Mining partnership', + SINGLE_WORKER_PER_ROOM: 'One farm per land', + DOUBLE_DRONE_COST: 'Costly knights', + DOUBLE_SCOUT_COST: 'Costly bishops', + CHEAP_DRONES: "In the king's pay", + NONE: 'No law', + } as Record + )[trait] ?? 'No law' + ) +} + +export function lawText(trait: WorldTrait): string { + const knight = getPieceCost('DRONE', trait) + const bishop = getPieceCost('HIVESCOUT', trait) + switch (trait) { + case 'TANDEM_MINING': + return 'Ore and herbs are only mined once 2 of your pieces stand in the same land.' + case 'SINGLE_WORKER_PER_ROOM': + return 'Each land may hold at most one farmer.' + case 'DOUBLE_DRONE_COST': + return `Knights cost ${knight} gold instead of ${BASE_PIECE_COSTS.DRONE}.` + case 'DOUBLE_SCOUT_COST': + return `Bishops cost ${bishop} gold instead of ${BASE_PIECE_COSTS.HIVESCOUT}.` + case 'CHEAP_DRONES': + return `Knights cost only ${knight} gold instead of ${BASE_PIECE_COSTS.DRONE}.` + default: + return 'No special rule today.' + } +} + +export function placementError( + unit: UnitType, + error: PlacementError, + cost: number, + gold: number, +): string { + switch (error) { + case 'NOT_AFFORDABLE': + return `Not enough gold: the ${UNIT_NAME[unit].toLowerCase()} costs ${cost}, you have ${gold}.` + case 'UNIT_LIMIT': + return 'You only have one castle.' + case 'OCCUPIED': + return 'There is already a piece here.' + case 'LAKE': + return 'Nothing can be built on a lake.' + case 'BIOME_FORBIDDEN': + return unit === 'HIVEMOTHER' + ? 'A castle cannot stand in the moor.' + : 'A farmer cannot work in the moor.' + case 'ZONE_UNIT_LIMIT': + return 'Law of the realm: this land already has a farmer.' + case 'NO_MOTHER': + return 'Place your castle first – it founds your realm.' + case 'IN_ZOI': + return 'This land is already claimed – nothing may be built here.' + case 'DOCK_OTHER_ZONE': + return `The ${UNIT_NAME[unit].toLowerCase()} cannot connect here: the neighbouring domain lies in another land.` + case 'NOT_DOCKED': + if (unit === 'DRONE') + return "The knight's domain must touch your domain in the same land." + if (unit === 'HIVESCOUT') + return "The bishop's domain must touch your domain." + return 'The farmer must border your domain directly, in the same land.' + default: + return 'That does not fit here.' + } +} + +/** Kurze Hinweise im Spiel statt einer Textwand (Jury: „contextual hints“) */ +export const HINTS = { + castle: + 'Tap a cell to place your castle. It claims land in straight lines – nothing can be built on claimed land later.', + farmer: + 'Farmers are free: place one right next to your claimed land, in the same land. Pasture pays 5 gold per cell.', + general: + 'Ore ⛰ and herbs 🌿 pay 20 gold. Knights pay double. Keep going until no move is left.', + touch: 'Tap again to place.', +} as const + +export function starsLine(count: number): string { + return '★'.repeat(count) + '☆'.repeat(3 - count) +} diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index 61c3c0a..bf54616 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -5,5 +5,6 @@ "lib": ["DOM", "ES2023"], "customConditions": ["browser"] }, + "include": ["**/*.ts", "../types/*.d.ts"], "references": [{"path": "../shared/"}, {"path": "../engine/"}] } diff --git a/src/engine/app/board/BoardView.ts b/src/engine/app/board/BoardView.ts new file mode 100644 index 0000000..777b641 --- /dev/null +++ b/src/engine/app/board/BoardView.ts @@ -0,0 +1,901 @@ +/** + * BoardView.ts + * SVG-Spielfeld. Wird einmal pro Map aufgebaut (setBoard); danach ändert update() nur die Felder, + * deren Zustand sich wirklich geändert hat – kein Neuaufbau von 81 Feldern pro Zug. + * + * Aufbau (von unten nach oben): + * Hintergrund → Felder (Gelände-Kachel, Landstrich-Tönung, Schatz, beanspruchtes Land, Figur, Rahmen) + * → Gitterlinien → Landstrich-Grenzen + * + * Pixel-Look: Gelände und Figuren sind 16-px-Sprites aus public/sprites/board.png (Atlas, siehe atlas.ts; + * „16x16 Mini World Sprites“ von Shade & octoshrimpy, CC0). Ein Feld = 100 Einheiten = 16 Sprite-Pixel. + * + * Dieselbe Klasse zeichnet auch Mini-Maps (Debriefing, Karten-Archiv): interactive = false. + * Im 1v1 tragen Steine einen Ring in der Spielerfarbe (owner-p1/-p2) und die ZoI ist nach Besitzer + * getönt (zoi-p1, zoi-p2, zoi-both) statt nach Einheitentyp. + * Klassen am Feld (cell, in-zoi, cell-lake, piece-token, token-*, preview-*) sind auch Test-Schnittstelle. + */ + +import { SIZE } from '../../core/Board'; +import type { Board } from '../../core/Board'; +import { BANDIT } from '../../core/Game'; +import type { Game } from '../../core/Game'; +import { RESOURCE_DEFS } from '../../core/Types'; +import { UNIT_CODE, UNIT_TYPES } from '../../core/units'; +import type { UnitType } from '../../core/units'; +import { ZONE_COLOR_PALETTE } from '../../core/Zones'; +import { TEAM_HEX } from '../../core/avatars'; +import type { Faction, TeamColor } from '../../core/avatars'; +import { DEFAULT_BANNER, DEFAULT_THEME } from '../../core/cosmetics'; +import type { Banner, Theme } from '../../core/cosmetics'; +import { spriteUrl } from './atlas'; +import type { SpriteName } from './atlas'; +import { BADGE_HEIGHT, CELL, badgeOrigin, edgePath } from './geometry'; +import './board.css'; +import { assetUrl } from '../env'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +type Attrs = Record; + +function svgEl( + tag: K, + attrs: Attrs = {}, + ...children: (Node | string)[] +): SVGElementTagNameMap[K] { + const el = document.createElementNS(SVG_NS, tag); + for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value)); + el.append(...children); + return el; +} + +function useIcon(id: string, x: number, y: number, size: number, cls: string): SVGUseElement { + return svgEl('use', { href: `#${id}`, x, y, width: size, height: size, class: cls }); +} + +/** Ein 16×16-Sprite (eigene kleine PNG) an Position x/y in Feldgröße size. */ +function sprite(name: SpriteName, x: number, y: number, size: number, cls = ''): SVGImageElement { + const el = svgEl('image', { x, y, width: size, height: size, class: `sprite ${cls}`.trim() }); + setSprite(el, name); + return el; +} + +function setSprite(el: SVGImageElement, name: SpriteName): void { + el.setAttribute('href', themedUrl(name)); + el.dataset.sprite = name; +} + +// ========================================== +// Brett-Thema und Banner (Issue #67): nur Aussehen +// ========================================== + +/** Gelände-Kacheln, die ein Thema ersetzt (scripts/make-themes.py) */ +const THEMED = new Set([ + ...[0, 1, 2, 3, 4, 5].flatMap((i) => [`grass${i}`, `moor${i}`]), + 'water0', + 'water1', + 'rock0', + 'rock1', + 'rock2', + 'house', + 'chapel', + 'tree', + 'well', + 'cave', + 'hut', +]); + +let boardTheme: Theme = DEFAULT_THEME; + +function themedUrl(name: SpriteName): string { + return boardTheme !== 'classic' && THEMED.has(name) + ? assetUrl(`/sprites/themes/${boardTheme}/${name}.png`) + : spriteUrl(name); +} + +/** Thema für alle Bretter; schon gezeichnete Kacheln wechseln sofort */ +export function setBoardTheme(theme: Theme): void { + if (theme === boardTheme) return; + boardTheme = theme; + for (const el of document.querySelectorAll('image.sprite[data-sprite]')) { + if (THEMED.has(el.dataset.sprite!)) el.setAttribute('href', themedUrl(el.dataset.sprite as SpriteName)); + } +} + +/** + * Banner-Formen in Feld-Einheiten (100 = ein Feld, 6,25 = ein Pixel): Tuch in der Teamfarbe, dazu ein Zeichen + * (Pergament, die Krone golden). Die Stange steht bei allen gleich. + */ +export const BANNER_SHAPES: Record = { + classic: { cloth: 'M12.5 6.25h31.25v25h-12.5l-6.25 -6.25 -12.5 0z' }, + pennant: { cloth: 'M12.5 6.25L56.25 18.75L12.5 31.25z' }, + swallowtail: { cloth: 'M12.5 6.25H56.25L43.75 18.75L56.25 31.25H12.5z' }, + hanging: { cloth: 'M12.5 6.25H43.75V50L28.125 40.625L12.5 50z' }, + stripe: { cloth: 'M12.5 6.25H50V31.25H12.5z', emblem: 'M12.5 15.625H50V21.875H12.5z' }, + cross: { cloth: 'M12.5 6.25H50V31.25H12.5z', emblem: 'M25 6.25H31.25V31.25H25zM12.5 15.625H50V21.875H12.5z' }, + star: { cloth: 'M12.5 6.25H50V31.25H12.5z', emblem: 'M31.25 9.375L37.5 18.75L31.25 28.125L25 18.75z' }, + crown: { + cloth: 'M12.5 6.25H50V31.25H12.5z', + emblem: 'M21.875 25V12.5L26.5625 17.1875L31.25 10.9375L35.9375 17.1875L40.625 12.5V25z', + }, + // Fledermaus (Halloween-Fest, Issue #93), dunkel auf dem Tuch + bat: { + cloth: 'M12.5 6.25H50V31.25H12.5z', + emblem: + 'M15.625 15.625L21.875 12.5L25 18.75L28.125 15.625L31.25 18.75L34.375 15.625L37.5 18.75L40.625 12.5L46.875 15.625L43.75 25L37.5 21.875L31.25 28.125L25 21.875L18.75 25z', + }, + // Schneeflocke (Winterfest) und Osterei (Osterfest), Issue #93 + snowflake: { + cloth: 'M12.5 6.25H50V31.25H12.5z', + emblem: + 'M28.125 9.375H34.375V28.125H28.125zM18.75 15.625H43.75V21.875H18.75zM21.875 9.375H25V12.5H21.875zM37.5 9.375H40.625V12.5H37.5zM21.875 25H25V28.125H21.875zM37.5 25H40.625V28.125H37.5z', + }, + egg: { + cloth: 'M12.5 6.25H50V31.25H12.5z', + emblem: 'M31.25 9.375L37.5 12.5L40.625 18.75L37.5 25L31.25 28.125L25 25L21.875 18.75L25 12.5z', + }, +}; + +/** Farbe des Zeichens: Krone und Osterei golden, Fledermaus dunkel, sonst Pergament */ +const emblemClass = (banner: Banner) => + `banner-emblem${banner === 'crown' || banner === 'egg' ? ' emblem-gold' : banner === 'bat' ? ' emblem-dark' : ''}`; + +/** Banner je Besitzer (Index wie ownerColor: 1 = Spieler 1, 2 = Spieler 2) */ +let ownerBanner: [Banner, Banner, Banner] = [DEFAULT_BANNER, DEFAULT_BANNER, DEFAULT_BANNER]; + +export function setBoardBanners(banners: [Banner, Banner]): void { + ownerBanner = [DEFAULT_BANNER, banners[0], banners[1]]; +} + +/** Kleines Banner als eigenes SVG (Auswahl im Profil) */ +export function bannerPreview(banner: Banner, colorHex: string): SVGSVGElement { + const shape = BANNER_SHAPES[banner]; + const svg = svgEl('svg', { viewBox: '0 0 62.5 62.5', class: `banner-preview banner-${banner}` }); + svg.append( + svgEl('rect', { class: 'banner-pole', x: 6.25, y: 3.125, width: 6.25, height: 56.25 }), + svgEl('path', { class: 'banner-cloth', d: shape.cloth, fill: colorHex }) + ); + if (shape.emblem) svg.append(svgEl('path', { class: emblemClass(banner), d: shape.emblem })); + return svg; +} + +const PIECE: Record = { + HIVEMOTHER: 'castle', + DRONE: 'knight', + HIVEWORKER: 'farmer', + HIVESCOUT: 'bishop', +}; + +/** + * Teamfarbe und Volk je Besitzer (0 = Solo wie Spieler 1); gewählt im Profil (Issues #48, #52), gesetzt per + * setBoardLook + */ +let ownerColor: [TeamColor, TeamColor, TeamColor] = ['red', 'red', 'cyan']; +let ownerFaction: [Faction, Faction, Faction] = ['kingdom', 'kingdom', 'kingdom']; + +/** Farben und Völker von Spieler 1 und 2 für alle Bretter; Steine bekommen sie beim nächsten Zeichnen */ +export function setBoardLook(colors: [TeamColor, TeamColor], factions: [Faction, Faction]): void { + ownerColor = [colors[0], colors[0], colors[1]]; + ownerFaction = [factions[0], factions[0], factions[1]]; +} + +/** Sprite einer Figur: Königreich ohne Vorsilbe (castle-red), andere Völker mit (orc-castle-red) */ +export function pieceSpriteName(unit: UnitType, color: TeamColor, faction: Faction): SpriteName { + return `${faction === 'kingdom' ? '' : `${faction}-`}${PIECE[unit]}-${color}` as SpriteName; +} + +function pieceSprite(unit: UnitType, owner: number): SpriteName { + return pieceSpriteName(unit, ownerColor[owner] ?? ownerColor[1], ownerFaction[owner] ?? ownerFaction[1]); +} + +/** Gleichmäßig verteilte, aber feste Kachel-Variante je Feld und Map (gleiche Map → gleiches Bild). */ +function variant(seed: number, i: number, n: number): number { + return (Math.imul(seed ^ (i * 2654435761), 2246822519) >>> 0) % n; +} + +const TOKEN_CLASS: Record = { + HIVEMOTHER: 'token-mother', + DRONE: 'token-drone', + HIVEWORKER: 'token-worker', + HIVESCOUT: 'token-scout', +}; + +export function zoneColor(zone: number): string { + return ZONE_COLOR_PALETTE[zone % ZONE_COLOR_PALETTE.length]; +} + +/** Welche Einheit die ZoI eines Felds „anzeigt“, wenn mehrere es abdecken (wie bisher: M > D > W > S). */ +function topZoIUnit(mask: number): UnitType | null { + for (const unit of UNIT_TYPES) if (mask & (1 << UNIT_CODE[unit])) return unit; + return null; +} + +/** 1v1: wessen ZoI ein Feld abdeckt – p1, p2 oder both. */ +function ownerOfZoI(game: Game, i: number): string { + const a = game.zoiOwn[0][i] > 0; + const b = game.zoiOwn[1][i] > 0; + return a && b ? 'both' : a ? 'p1' : 'p2'; +} + +/** Mit Stärke („realms“, Räuber): wer ein Feld hält – p1, p2, both (umkämpft), bandit oder '' (niemand). */ +function holderClass(game: Game, i: number): string { + const h = game.holder(i); + return h === -1 ? '' : h === -2 ? 'both' : h === BANDIT ? 'bandit' : `p${h + 1}`; +} + +/** Feld für das Banner eines Landstrichs: das bebaubare Feld am nächsten am Schwerpunkt. */ +function bannerCell(board: Board, zone: number): number { + const cells = board.zoneCells[zone]; + const land = cells.filter((c) => !board.lake[c]); + const pool = land.length > 0 ? land : cells; + const cx = pool.reduce((s, c) => s + board.x(c), 0) / pool.length; + const cy = pool.reduce((s, c) => s + board.y(c), 0) / pool.length; + let best = pool[0]; + let bestD = Infinity; + for (const c of pool) { + const d = (board.x(c) - cx) ** 2 + (board.y(c) - cy) ** 2; + if (d < bestD) { + bestD = d; + best = c; + } + } + return best; +} + +export interface PlacementPreviewView { + cell: number; + unit: UnitType; + /** Felder, die die neue ZoI abdecken würde */ + zoi: readonly number[]; + /** Text im Punkte-Badge, z. B. „+14“ oder „−10“ */ + label: string; + /** Regeln „realms“: Felder, die der Zug dem Gegner abnehmen würde */ + captured?: readonly number[]; + /** Besitzer der neuen Figur (1 = Solo/Spieler 1, 2 = Spieler 2) – bestimmt die Farbe der Vorschau */ + owner?: number; + /** warn = Zug würde die Partie sofort beenden */ + tone: 'gain' | 'loss' | 'neutral' | 'warn'; +} + +interface CellRefs { + g: SVGGElement; + zoi: SVGRectElement; + token: SVGImageElement | null; + /** 1v1: Ring in der Spielerfarbe unter dem Stein */ + ring: SVGRectElement | null; + badge: SVGImageElement | null; + /** „realms“: gekreuzte Schwerter auf angreifbaren Feldern (nur solange angreifbar) */ + attack: SVGUseElement | null; + /** Kurzform des zuletzt gezeichneten Zustands; nur bei Änderung wird das DOM angefasst. */ + state: string; +} + +export interface BoardViewOptions { + /** Klicks, Hover-Vorschau und Tastatur (Spielansicht); sonst statische Mini-Map */ + interactive: boolean; + className?: string; +} + +export interface BoardEvents { + /** touch = Eingabe per Finger/Stift (für „antippen, dann bestätigen“) */ + click(cell: number, touch: boolean): void; + hover(cell: number | null): void; +} + +export class BoardView { + readonly svg: SVGSVGElement; + private readonly cellLayer: SVGGElement; + private readonly gridLines: SVGPathElement; + private readonly zoneBorders: SVGPathElement; + /** Über allem: Geister-Stein und Punkte-Badge der Vorschau (einmal erzeugt, nur Attribute ändern sich) */ + private readonly overlay: SVGGElement; + private readonly ghost: SVGImageElement; + /** ZoI-Vorschau als ein Pfad: ein Attribut statt Klassenwechsel an bis zu ~12 Feldern pro Hover */ + private readonly zoiPreview: SVGPathElement; + private readonly badge: SVGGElement; + private readonly badgeRect: SVGRectElement; + private readonly badgeText: SVGTextElement; + private readonly badgeWarn: SVGUseElement; + private readonly targetRing: SVGRectElement; + /** Ebene für aufsteigende Gold-Zahlen (über allem, ohne Zeiger-Ereignisse) */ + private readonly overlayFloats: SVGGElement; + /** Regeln „realms“: ein Banner je Landstrich in der Farbe des Herrschers */ + private readonly bannerLayer: SVGGElement; + /** Kantenlänge der aktuellen Karte und Hilfen für Spalte/Zeile eines Felds */ + private size = SIZE; + private readonly bg: SVGRectElement; + private readonly frame: SVGRectElement; + + private cx(i: number): number { + return i % this.size; + } + + private cy(i: number): number { + return (i / this.size) | 0; + } + + /** Wahrzeichen und Namen der Lande (nur auf dem Spielbrett, nicht auf Mini-Karten) */ + private readonly landLayer: SVGGElement; + private lands: { cell: number; mark: SVGImageElement; label: SVGTextElement }[] = []; + private readonly showLands: boolean; + /** Räuberlager (Zelt je Lager; ausgehobene grau) */ + private readonly campLayer: SVGGElement; + private campSprites = new Map(); + /** Nebel des Krieges: ein Wolken-Feld je Zelle über allem außer Vorschau */ + private readonly fogLayer: SVGGElement; + private fogRects: SVGRectElement[] = []; + private fogged: Uint8Array | null = null; + private banners: { g: SVGGElement; ruler: number }[] = []; + private readonly capturePreview: SVGPathElement; + private legal: Uint8Array | null = null; + private cells: CellRefs[] = []; + private board: Board | null = null; + private preview = -1; + private cursor = -1; + private highlighted: number | null = null; + /** Nach setBoard() erscheinen vorhandene Steine ohne Animation (z. B. Mini-Map, Sprachwechsel). */ + private animate = false; + + constructor(options: BoardViewOptions) { + const pad = 4; + this.cellLayer = svgEl('g', { class: 'cells' }); + this.gridLines = svgEl('path', { class: 'grid-lines' }); + this.zoneBorders = svgEl('path', { class: 'zone-borders' }); + this.ghost = sprite('castle-red', 0, 0, CELL, 'ghost-token'); + this.badgeRect = svgEl('rect', { height: BADGE_HEIGHT }); + this.badgeText = svgEl('text', { y: 24 }); + this.badgeWarn = useIcon('i-warn', 10, 9, 28, 'badge-warn'); + this.badge = svgEl('g', { class: 'preview-badge' }, this.badgeRect, this.badgeWarn, this.badgeText); + this.zoiPreview = svgEl('path', { class: 'zoi-preview-path' }); + this.capturePreview = svgEl('path', { class: 'capture-preview-path' }); + this.overlayFloats = svgEl('g', { class: 'board-floats' }); + this.bannerLayer = svgEl('g', { class: 'region-banners' }); + this.campLayer = svgEl('g', { class: 'camps' }); + this.landLayer = svgEl('g', { class: 'land-marks' }); + this.showLands = options.interactive === true; + this.fogLayer = svgEl('g', { class: 'fog' }); + this.overlay = svgEl( + 'g', + { class: 'board-overlay', display: 'none' }, + this.zoiPreview, + this.capturePreview, + this.ghost, + this.badge + ); + this.targetRing = svgEl('rect', { + class: 'tutorial-ring', + width: CELL - 8, + height: CELL - 8, + display: 'none', + }); + const px = SIZE * CELL; + this.bg = svgEl('rect', { class: 'board-bg', width: px, height: px }); + this.frame = svgEl('rect', { class: 'board-frame', width: px, height: px }); + this.svg = svgEl( + 'svg', + { + viewBox: `${-pad} ${-pad} ${px + 2 * pad} ${px + 2 * pad}`, + class: ['board', options.interactive ? 'board-interactive' : 'board-static', options.className ?? ''] + .join(' ') + .trim(), + }, + this.bg, + this.cellLayer, + this.campLayer, + this.gridLines, + this.zoneBorders, + this.landLayer, + this.bannerLayer, + this.fogLayer, + this.frame, + this.targetRing, + this.overlay, + this.overlayFloats + ); + if (options.interactive) { + // Fokussierbar für die Tastatur-Steuerung (Pfeiltasten + Enter, siehe GameView) + this.svg.setAttribute('tabindex', '0'); + this.svg.setAttribute('role', 'application'); + } else { + this.svg.setAttribute('aria-hidden', 'true'); + } + } + + /** Ein delegierter Listener für alle Felder statt Listener pro Feld. */ + listen(events: BoardEvents): void { + const cellOf = (target: EventTarget | null): number | null => { + const g = (target as Element | null)?.closest?.('.cell') as SVGGElement | null; + return g ? Number(g.dataset.i) : null; + }; + // Zeigertyp merken: click-Events sind nicht in allen Browsern PointerEvents + let lastPointer = 'mouse'; + this.svg.addEventListener('pointerdown', (e) => (lastPointer = e.pointerType)); + this.svg.addEventListener('click', (e) => { + const i = cellOf(e.target); + if (i !== null) events.click(i, lastPointer === 'touch' || lastPointer === 'pen'); + }); + // Hover nur mit Maus/Stift: bei Berührung feuert pointerover beim Aufsetzen und pointerleave beim Abheben – + // die Info zum Feld blitzte nur kurz auf (Issue #56). Am Handy wählt stattdessen der erste Tipp das Feld. + this.svg.addEventListener('pointerover', (e) => { + if (e.pointerType !== 'touch') events.hover(cellOf(e.target)); + }); + this.svg.addEventListener('pointerleave', (e) => { + if (e.pointerType !== 'touch') events.hover(null); + }); + } + + /** Neue Map: Gelände, Zonengrenzen und Feld-Gruppen einmalig aufbauen. */ + setBoard(board: Board): void { + this.board = board; + // Kartengröße: Koordinaten, Rahmen und viewBox folgen der Karte + this.size = board.size; + const px = board.size * CELL; + for (const rect of [this.bg, this.frame]) { + rect.setAttribute('width', String(px)); + rect.setAttribute('height', String(px)); + } + this.svg.setAttribute('viewBox', `-4 -4 ${px + 8} ${px + 8}`); + this.preview = -1; + this.cursor = -1; + this.highlighted = null; + this.legal = null; + this.overlay.setAttribute('display', 'none'); + this.targetRing.setAttribute('display', 'none'); + this.animate = false; + this.cells = []; + const groups: SVGGElement[] = []; + + for (let i = 0; i < board.cells; i++) { + const biome = board.biomeAt(i); + const isLake = board.lake[i] === 1; + const resource = board.resource[i]; + const terrainClass = isLake + ? 'cell-lake' + : biome === 'MOUNTAIN' + ? 'cell-biome-mountain' + : biome === 'SWAMP' + ? 'cell-biome-swamp' + : 'cell-plains'; + + // Beanspruchtes Land: Pixel-Raster (Maske) in der Besitzerfarbe über die ganze Kachel + const zoi = svgEl('rect', { + class: 'cell-zoi', + width: CELL, + height: CELL, + mask: 'url(#m-dither)', + display: 'none', + }); + const g = svgEl('g', { + class: `cell ${terrainClass}`, + transform: `translate(${this.cx(i) * CELL} ${this.cy(i) * CELL})`, + 'data-i': i, + 'data-x': this.cx(i), + 'data-y': this.cy(i), + 'data-zone': board.zone[i], + }); + + // Gelände-Kachel: Weide, Moor, Wasser; Gebirge = Weide mit Felsen + const v = variant(board.seed, i, 6); + const base: SpriteName = isLake + ? (`water${v % 2}` as SpriteName) + : biome === 'SWAMP' + ? (`moor${v}` as SpriteName) + : (`grass${v}` as SpriteName); + g.append(sprite(base, 0, 0, CELL, 'cell-terrain')); + // Landstrich-Tönung (Legende, Hervorheben) – leicht, damit das Gelände lesbar bleibt + g.append(svgEl('rect', { class: 'cell-bg', width: CELL, height: CELL, fill: zoneColor(board.zone[i]) })); + // Wasser: zwei kleine Wellen im Pixelraster (6,25 Einheiten = 1 Pixel), je Feld leicht versetzt + if (isLake) { + const wx = 18.75 + (v % 3) * 6.25; + g.append( + svgEl('path', { + class: 'cell-waves', + d: `M${wx} 37.5h18.75v6.25h-18.75zM${wx + 31.25} 62.5h18.75v6.25h-18.75z`, + }) + ); + } + if (biome === 'MOUNTAIN' && !isLake && !resource) { + g.append(sprite(`rock${v % 3}` as SpriteName, 0, 0, CELL, 'cell-watermark')); + } + + if (resource) { + g.append( + svgEl( + 'g', + { class: `cell-resource res-${resource.toLowerCase()}` }, + sprite(resource === 'ORE' ? 'ore' : 'herbs', 0, 0, CELL, 'res-icon'), + svgEl('text', { x: 50, y: 88, class: 'res-pts' }, `+${RESOURCE_DEFS[resource].points}`) + ) + ); + } + + g.append( + zoi, + svgEl('rect', { class: 'legal-mark', x: 9.375, y: 9.375, width: 81.25, height: 81.25 }), + svgEl('rect', { class: 'cell-frame', x: 3.125, y: 3.125, width: 93.75, height: 93.75 }) + ); + groups.push(g); + this.cells.push({ g, zoi, token: null, ring: null, badge: null, attack: null, state: '' }); + } + + this.cellLayer.replaceChildren(...groups); + this.bannerLayer.replaceChildren(); + this.banners = []; + this.campLayer.replaceChildren(); + this.campSprites.clear(); + this.renderLandMarks(board); + this.fogRects = Array.from({ length: board.cells }, (_, i) => + svgEl('rect', { + class: 'fog-cell', + x: this.cx(i) * CELL - 1, + y: this.cy(i) * CELL - 1, + width: CELL + 2, + height: CELL + 2, + display: 'none', + }) + ); + this.fogLayer.replaceChildren(...this.fogRects); + this.fogged = null; + this.gridLines.setAttribute('d', edgePath(board.zone, false, board.size)); + this.zoneBorders.setAttribute('d', edgePath(board.zone, true, board.size)); + } + + /** Steine, ZoI und Rohstoff-Sichtbarkeit nachziehen. game = null zeigt nur das Gelände. */ + update(game: Game | null): void { + const board = this.board; + if (!board) return; + + const multi = game !== null && game.players > 1; + // „realms“: Land zeigt, wer es hält (auch Felder mit Figuren); Schätze bleiben sichtbar – fremdes Land + // lässt sich erobern + const realms = game?.usesStrength === true; + for (let i = 0; i < this.cells.length; i++) { + const refs = this.cells[i]; + const unit = game?.unitAt(i) ?? null; + const held = realms ? holderClass(game, i) : ''; + const zoiUnit = realms + ? held + ? (topZoIUnit(game.zoiMask[i]) ?? 'HIVEMOTHER') + : null + : game + ? topZoIUnit(game.zoiMask[i]) + : null; + const resource = board.resource[i]; + const covered = !realms && !!resource && !unit && zoiUnit !== null; + const badge = unit && resource ? (game!.mined[i] ? 'mined' : 'pending') : ''; + const owner = multi && unit ? game.owner[i] : 0; + const zoiOwner = realms ? held : multi && zoiUnit ? ownerOfZoI(game, i) : ''; + const color = unit ? pieceSprite(unit, owner || 1) : ''; + const state = `${unit ?? ''}|${zoiUnit ?? ''}|${covered ? 1 : 0}|${badge}|${owner}|${zoiOwner}|${color}`; + if (state === refs.state) continue; + refs.state = state; + + const g = refs.g; + g.classList.toggle('in-zoi', zoiUnit !== null && board.lake[i] === 0); + g.classList.toggle('has-unit', unit !== null); + g.classList.toggle('res-off', covered || unit !== null); + + if (zoiUnit) { + // Farbe nach Besitzer (1v1) bzw. einheitlich (Solo) – per CSS-Klasse + refs.zoi.setAttribute('class', zoiOwner ? `cell-zoi zoi-${zoiOwner}` : 'cell-zoi zoi-solo'); + refs.zoi.removeAttribute('display'); + } else { + refs.zoi.setAttribute('display', 'none'); + } + + const tokenKey = unit ? pieceSprite(unit, owner || 1) : undefined; + if (refs.token?.dataset.key !== tokenKey) { + refs.token?.remove(); + refs.ring?.remove(); + refs.token = null; + refs.ring = null; + if (unit) { + const token = sprite(pieceSprite(unit, owner || 1), 0, 0, CELL, `piece-token ${TOKEN_CLASS[unit]}`); + token.dataset.unit = unit; + token.dataset.key = tokenKey; + if (this.animate) token.classList.add('token-placed'); + refs.token = token; + g.insertBefore(token, refs.zoi.nextSibling); + if (owner) { + // Sockel unter der Figur in der Spielerfarbe (2 Pixel hoch, 10 breit) + refs.ring = svgEl('rect', { + class: `owner-ring owner-p${owner}`, + x: 18.75, + y: 81.25, + width: 62.5, + height: 12.5, + 'data-owner': owner, + }); + g.insertBefore(refs.ring, token); + } + } + } + + refs.badge?.remove(); + refs.badge = null; + if (badge && resource) { + // Halbe Kachel (8 Pixel) unten rechts: Münze = abgebaut, blasse Münze = wartet auf 2. Figur + refs.badge = sprite('coin', 50, 50, 50, `cell-badge badge-${badge}`); + g.insertBefore(refs.badge, g.lastChild); + } + } + this.renderCamps(game); + // Wahrzeichen weichen Figuren und Lagern auf ihrem Feld + for (const land of this.lands) { + const covered = game !== null && (game.piece[land.cell] !== 0 || game.camps.includes(land.cell)); + land.mark.classList.toggle('hidden-mark', covered); + } + this.animate = true; + } + + /** + * Jedes Land bekommt ein kleines Wahrzeichen (unten rechts im Banner-Feld) und seinen Namen als Karten- + * Beschriftung – Gebirge: Höhle, Moor: Hütte, Weideland mit See: Brunnen, sonst Haus, Kapelle oder Baum. + */ + private renderLandMarks(board: Board): void { + this.lands = []; + if (!this.showLands) { + this.landLayer.replaceChildren(); + return; + } + const plains: SpriteName[] = ['house', 'chapel', 'tree']; + for (let z = 0; z < board.zoneCount; z++) { + const cell = bannerCell(board, z); + const biome = board.zoneBiome[z]; + const name: SpriteName = + biome === 'MOUNTAIN' + ? 'cave' + : biome === 'SWAMP' + ? 'hut' + : board.zoneLakeCount[z] > 0 + ? 'well' + : plains[(z + board.seed) % plains.length]; + const x = this.cx(cell) * CELL; + const y = this.cy(cell) * CELL; + const mark = sprite(name, x + 50, y + 37.5, 50, 'land-mark'); + const label = svgEl('text', { class: 'land-label', x: x + 50, y: y + 96 }, ''); + label.dataset.zone = String(z); + this.lands.push({ cell, mark, label }); + } + this.landLayer.replaceChildren(...this.lands.flatMap((l) => [l.mark, l.label])); + } + + /** Namen der Lande (in der aktuellen Sprache) – Index = Landstrich */ + setLandNames(names: readonly string[]): void { + this.lands.forEach((land, z) => { + if (land.label.textContent !== names[z]) land.label.textContent = names[z] ?? ''; + }); + } + + /** Räuberlager zeichnen: Zelt auf dem Lager-Feld, ausgehobene Lager grau. */ + private renderCamps(game: Game | null): void { + const camps = game?.bandits ? game.camps : []; + if (camps.length === 0) { + if (this.campSprites.size) { + this.campLayer.replaceChildren(); + this.campSprites.clear(); + } + return; + } + camps.forEach((c, k) => { + let el = this.campSprites.get(c); + if (!el) { + el = sprite('camp', this.cx(c) * CELL, this.cy(c) * CELL, CELL, 'camp-token'); + el.dataset.cell = String(c); + this.campLayer.append(el); + this.campSprites.set(c, el); + } + el.classList.toggle('camp-fallen', game!.campAlive[k] === 0); + }); + } + + /** Nebel des Krieges: 1 = sichtbar; null = kein Nebel. Verdeckte Felder bekommen eine Wolke. */ + setFog(visible: Uint8Array | null): void { + for (let i = 0; i < this.fogRects.length; i++) { + const hidden = visible !== null && visible[i] === 0; + const was = this.fogged?.[i] === 1; + if (hidden === was) continue; + this.fogRects[i].setAttribute('display', hidden ? 'inline' : 'none'); + this.cells[i]?.g.classList.toggle('fogged', hidden); + } + this.fogged = visible ? visible.map((v) => (v ? 0 : 1)) : null; + } + + /** + * Regeln „realms“: Banner je Landstrich (Index = Zone) in der Farbe des Herrschers, -1 = neutrales Banner. + * null blendet alle Banner aus (Solo, „classic“, Mini-Karten). + */ + setRulers(rulers: readonly number[] | null): void { + const board = this.board; + if (!board) return; + if (!rulers) { + this.bannerLayer.setAttribute('display', 'none'); + return; + } + this.bannerLayer.removeAttribute('display'); + if (this.banners.length !== board.zoneCount) { + this.banners = []; + const groups: SVGGElement[] = []; + for (let z = 0; z < board.zoneCount; z++) { + const c = bannerCell(board, z); + // Pixel-Fahne oben links im Feld: Stange (1 × 9 px) und Tuch (5 × 4 px mit Zacke) + const g = svgEl( + 'g', + { class: 'region-banner banner-none', transform: `translate(${this.cx(c) * CELL} ${this.cy(c) * CELL})` }, + svgEl('rect', { class: 'banner-pole', x: 6.25, y: 3.125, width: 6.25, height: 56.25 }), + svgEl('path', { class: 'banner-cloth', d: BANNER_SHAPES.classic.cloth }), + svgEl('path', { class: 'banner-emblem', d: '' }) + ); + g.dataset.zone = String(z); + groups.push(g); + this.banners.push({ g, ruler: -1 }); + } + this.bannerLayer.replaceChildren(...groups); + } + rulers.forEach((ruler, z) => { + const banner = this.banners[z]; + if (!banner) return; + const cls = ruler < 0 ? 'banner-none' : `banner-p${ruler + 1}`; + if (banner.ruler !== ruler || !banner.g.classList.contains(cls)) { + // Wechsel des Herrschers: kurz wehen lassen + const changed = banner.ruler !== ruler && ruler >= 0; + banner.g.setAttribute('class', `region-banner ${cls}${changed && this.animate ? ' banner-raised' : ''}`); + banner.g.dataset.ruler = String(ruler); + // Form des Herrschers (Issue #67); neutral = klassisch ohne Zeichen + const design = ruler < 0 ? DEFAULT_BANNER : (ownerBanner[ruler + 1] ?? DEFAULT_BANNER); + const shape = BANNER_SHAPES[design]; + const [, cloth, emblem] = banner.g.children; + cloth.setAttribute('d', shape.cloth); + emblem.setAttribute('d', shape.emblem ?? ''); + emblem.setAttribute('class', emblemClass(design)); + banner.g.dataset.banner = design; + banner.ruler = ruler; + } + }); + } + + /** + * Aufsteigende Zahl über einem Feld („+7“, „−10“) – kurze Rückmeldung wie in einem Spiel. + * Läuft per CSS-Animation und entfernt sich danach selbst. + */ + floatLabel(cell: number, text: string, tone: 'gain' | 'loss' | 'land', dy = 0): void { + if (!this.board) return; + const label = svgEl( + 'text', + { x: this.cx(cell) * CELL + CELL / 2, y: this.cy(cell) * CELL + 30 + dy, class: `float-label float-${tone}` }, + text + ); + this.overlayFloats.append(label); + label.addEventListener('animationend', () => label.remove()); + // Ohne Animation (reduzierte Bewegung, Effekte aus) trotzdem aufräumen + setTimeout(() => label.remove(), 1500); + } + + /** Goldener Ring um das vom Tutorial vorgeschlagene Feld; null blendet ihn aus. */ + setTarget(i: number | null): void { + if (i === null) { + this.targetRing.setAttribute('display', 'none'); + return; + } + this.targetRing.setAttribute('x', String(this.cx(i) * CELL + 4)); + this.targetRing.setAttribute('y', String(this.cy(i) * CELL + 4)); + this.targetRing.removeAttribute('display'); + } + + /** Tastatur-Cursor (heller Rahmen); null blendet ihn aus. */ + setCursor(i: number | null): void { + if (this.cursor >= 0) this.cells[this.cursor]?.g.classList.remove('cursor'); + this.cursor = i ?? -1; + if (i !== null) this.cells[i]?.g.classList.add('cursor'); + } + + /** Hover-Vorschau: grüner bzw. roter Rahmen um das Feld; null entfernt sie. */ + setPreview(i: number | null, valid = false): void { + if (this.preview >= 0) this.cells[this.preview]?.g.classList.remove('preview-valid', 'preview-invalid'); + this.preview = i ?? -1; + if (i !== null) this.cells[i]?.g.classList.add(valid ? 'preview-valid' : 'preview-invalid'); + } + + /** + * Regeln „realms“: gültige Felder in fremdem oder umkämpftem Land – ein Zug dorthin ist ein Angriff. + * Sie bekommen gekreuzte Schwerter statt des gestrichelten Rahmens (null = keine). + */ + setAttackable(bits: Uint8Array | null): void { + for (let i = 0; i < this.cells.length; i++) { + const on = bits?.[i] === 1; + const refs = this.cells[i]; + if (on === refs.g.classList.contains('attackable')) continue; + refs.g.classList.toggle('attackable', on); + if (on) { + refs.attack = useIcon('i-swords', 62.5, 3.125, 34.375, 'attack-mark'); + refs.g.append(refs.attack); + } else { + refs.attack?.remove(); + refs.attack = null; + } + } + } + + /** Eroberte Felder kurz aufblitzen lassen (Pixel-Schritte; ohne Animation passiert nichts). */ + flashCells(cells: readonly number[]): void { + for (const i of cells) { + const g = this.cells[i]?.g; + if (!g) continue; + g.classList.remove('captured-flash'); + void g.getBoundingClientRect(); + g.classList.add('captured-flash'); + setTimeout(() => g.classList.remove('captured-flash'), 900); + } + } + + /** Markiert alle Felder, auf die das aktive Werkzeug gesetzt werden darf (null = keine). */ + setLegal(bits: Uint8Array | null): void { + for (let i = 0; i < this.cells.length; i++) { + const on = bits?.[i] === 1; + if ((this.legal?.[i] === 1) !== on) this.cells[i].g.classList.toggle('legal', on); + } + this.legal = bits ? bits.slice() : null; + } + + /** Vorschau eines Zugs: ZoI der neuen Einheit, halbtransparenter Stein und Punkte-Badge. */ + setPlacementPreview(preview: PlacementPreviewView | null): void { + if (!preview) { + this.overlay.setAttribute('display', 'none'); + return; + } + // Farbe der Vorschau (Land, das die neue Figur beanspruchen würde) – wie die Teamfarbe des Besitzers + const color = TEAM_HEX[ownerColor[preview.owner ?? 1] ?? ownerColor[1]]; + this.zoiPreview.setAttribute( + 'd', + preview.zoi.map((i) => `M${this.cx(i) * CELL + 6.25} ${this.cy(i) * CELL + 6.25}h87.5v87.5h-87.5z`).join('') + ); + this.zoiPreview.setAttribute('fill', color); + // Farbenblind-Modus: Vorschau für Spieler 2 gestreift wie sein Land (board.css) + this.zoiPreview.classList.toggle('owner-p2', preview.owner === 2); + this.zoiPreview.setAttribute('stroke', color); + this.zoiPreview.dataset.cells = String(preview.zoi.length); // für Tests + const captured = preview.captured ?? []; + this.capturePreview.setAttribute( + 'd', + captured.map((i) => `M${this.cx(i) * CELL + 12.5} ${this.cy(i) * CELL + 12.5}h75v75h-75z`).join('') + ); + this.capturePreview.dataset.cells = String(captured.length); + const x = this.cx(preview.cell) * CELL; + const y = this.cy(preview.cell) * CELL; + const warn = preview.tone === 'warn'; + // Platz für das Warn-Icon links im Badge + const iconSpace = warn ? 30 : 0; + const width = 26 + iconSpace + preview.label.length * 15; + const { x: bx, y: by } = badgeOrigin(preview.cell, this.size, width); + setSprite(this.ghost, pieceSprite(preview.unit, preview.owner ?? 1)); + this.ghost.setAttribute('x', String(x)); + this.ghost.setAttribute('y', String(y)); + this.badge.setAttribute('class', `preview-badge tone-${preview.tone}`); + this.badge.setAttribute('transform', `translate(${bx} ${by})`); + this.badgeRect.setAttribute('width', String(width)); + this.badgeText.setAttribute('x', String(iconSpace + (width - iconSpace) / 2)); + this.badgeWarn.setAttribute('display', warn ? 'inline' : 'none'); + this.badgeText.textContent = preview.label; + this.overlay.removeAttribute('display'); + } + + /** Hebt eine Zone hervor (Legende); null hebt die Hervorhebung auf. */ + highlightZone(zone: number | null): void { + if (!this.board || zone === this.highlighted) return; + const toggle = (z: number | null, on: boolean) => { + if (z === null) return; + for (const i of this.board!.zoneCells[z]) this.cells[i].g.classList.toggle('zone-hl', on); + }; + toggle(this.highlighted, false); + toggle(zone, true); + this.highlighted = zone; + } +} + +/** Statische Mini-Map in einen Container zeichnen (Debriefing, Karten-Archiv). */ +export function renderMiniBoard(host: HTMLElement, board: Board, game: Game | null = null): void { + const view = new BoardView({ interactive: false, className: 'board-mini' }); + view.setBoard(board); + view.update(game); + host.replaceChildren(view.svg); +} diff --git a/src/engine/app/board/atlas.ts b/src/engine/app/board/atlas.ts new file mode 100644 index 0000000..6c9ea79 --- /dev/null +++ b/src/engine/app/board/atlas.ts @@ -0,0 +1,94 @@ +/** + * atlas.ts – von scripts/make-sprite-atlas.py erzeugt, nicht von Hand ändern. + * Alle Sprites (16×16 px, Burgen der Völker 32×32) liegen einzeln unter public/sprites/tiles/.png. + */ + +import { assetUrl } from '../env'; + +export const SPRITE_NAMES = [ + 'grass0', + 'moor0', + 'grass1', + 'moor1', + 'grass2', + 'moor2', + 'grass3', + 'moor3', + 'grass4', + 'moor4', + 'grass5', + 'moor5', + 'water0', + 'water1', + 'rock0', + 'rock1', + 'rock2', + 'ore', + 'herbs', + 'coin', + 'wheat', + 'select', + 'boxGreen', + 'boxRed', + 'boxYellow', + 'camp', + 'bandit', + 'house', + 'chapel', + 'tree', + 'well', + 'cave', + 'hut', + 'castle-red', + 'knight-red', + 'farmer-red', + 'bishop-red', + 'castle-cyan', + 'knight-cyan', + 'farmer-cyan', + 'bishop-cyan', + 'castle-lime', + 'knight-lime', + 'farmer-lime', + 'bishop-lime', + 'castle-purple', + 'knight-purple', + 'farmer-purple', + 'bishop-purple', + 'orc-castle-red', + 'orc-knight-red', + 'orc-farmer-red', + 'orc-bishop-red', + 'orc-castle-cyan', + 'orc-knight-cyan', + 'orc-farmer-cyan', + 'orc-bishop-cyan', + 'orc-castle-lime', + 'orc-knight-lime', + 'orc-farmer-lime', + 'orc-bishop-lime', + 'orc-castle-purple', + 'orc-knight-purple', + 'orc-farmer-purple', + 'orc-bishop-purple', + 'pirate-castle-red', + 'pirate-knight-red', + 'pirate-farmer-red', + 'pirate-bishop-red', + 'pirate-castle-cyan', + 'pirate-knight-cyan', + 'pirate-farmer-cyan', + 'pirate-bishop-cyan', + 'pirate-castle-lime', + 'pirate-knight-lime', + 'pirate-farmer-lime', + 'pirate-bishop-lime', + 'pirate-castle-purple', + 'pirate-knight-purple', + 'pirate-farmer-purple', + 'pirate-bishop-purple', +] as const; + +export type SpriteName = (typeof SPRITE_NAMES)[number]; + +export const spriteUrl = (name: SpriteName): string => assetUrl(`/sprites/tiles/${name}.png`); diff --git a/src/engine/app/board/board.css b/src/engine/app/board/board.css new file mode 100644 index 0000000..6ecf000 --- /dev/null +++ b/src/engine/app/board/board.css @@ -0,0 +1,560 @@ +/* + * board.css – Spielbrett im Pixel-Look (BoardView) und Mini-Karten. + * Ein Feld = 100 SVG-Einheiten = 16 Sprite-Pixel → 1 Pixel = 6,25 Einheiten. Linien und Rahmen + * halten sich an dieses Raster, keine Rundungen, keine weichen Schatten. + */ + +.board-host { + width: 100%; + aspect-ratio: 1 / 1; +} + +.board { + display: block; + width: 100%; + height: 100%; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* Sprites nie weichzeichnen */ +.board image, +.board .sprite { + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.board-bg { + fill: var(--px-ink, #2b1d14); +} + +.board-frame { + fill: none; + stroke: var(--px-ink, #2b1d14); + stroke-width: 12.5; +} + +/* ---------- Felder ---------- */ + +/* Landstrich-Tönung: nur ein Hauch, das Gelände bleibt lesbar */ +.cell-bg { + fill-opacity: 0.14; + mix-blend-mode: multiply; +} + +.cell.zone-hl .cell-bg { + fill-opacity: 0.4; +} + +.cell.has-unit .cell-watermark { + display: none; +} + +.res-pts { + fill: #fff7d6; + stroke: var(--px-ink, #2b1d14); + stroke-width: 4; + paint-order: stroke; + font-family: var(--font-pixel); + font-size: 26px; + font-weight: 700; + text-anchor: middle; +} + +/* Von beanspruchtem Land verdeckte oder bereits besetzte Schätze ausblenden */ +.cell.res-off .cell-resource { + display: none; +} + +.cell-frame { + fill: none; + stroke: transparent; + stroke-width: 6.25; +} + +/* Gitter: feine dunkle Linien; Landstrich-Grenzen: kräftig, eckig */ +.grid-lines { + fill: none; + stroke: var(--px-ink, #2b1d14); + stroke-opacity: 0.18; + stroke-width: 3.125; + pointer-events: none; +} + +.zone-borders { + fill: none; + stroke: var(--px-ink, #2b1d14); + stroke-opacity: 0.85; + stroke-width: 9.375; + stroke-linecap: square; + pointer-events: none; +} + +/* Beanspruchtes Land: Pixel-Raster (Maske m-dither) */ +.cell-zoi { + fill: var(--px-ink, #2b1d14); + fill-opacity: 0.38; +} + +.cell-zoi.zoi-p1 { + fill: var(--player-1); + fill-opacity: 0.55; +} + +.cell-zoi.zoi-p2 { + fill: var(--player-2); + fill-opacity: 0.55; +} + +.cell-zoi.zoi-both { + fill: var(--px-ink, #2b1d14); + fill-opacity: 0.6; +} + +/* Sockel in der Spielerfarbe unter der Figur (1v1) */ +.owner-ring { + stroke: var(--px-ink, #2b1d14); + stroke-width: 3.125; +} + +.owner-ring.owner-p1 { + fill: var(--player-1); +} + +.owner-ring.owner-p2 { + fill: var(--player-2); +} + +/* ---------- Farbenblind-Modus (Issue #91): Farbe nie allein ---------- */ + +.cvd-stripes-bg { + fill: var(--player-2); +} + +.cvd-stripes-line { + fill: var(--px-ink, #2b1d14); + fill-opacity: 0.4; +} + +/* Land von Spieler 2 (und seine Zug-Vorschau) gestreift */ +[data-cvd='on'] .cell-zoi.zoi-p2, +[data-cvd='on'] .zoi-preview-path.owner-p2 { + fill: url(#cvd-stripes); +} + +/* Sockel von Spieler 2 mit hellem, gestricheltem Rand */ +[data-cvd='on'] .owner-ring.owner-p2 { + stroke: #fff7d6; + stroke-dasharray: 6.25 3.125; +} + +/* Gesperrtes Feld unter dem Zeiger: gestrichelt statt nur rot */ +[data-cvd='on'] .board-interactive .cell.preview-invalid .cell-frame { + stroke-dasharray: 12.5 9.375; +} + +.cell-badge.badge-pending { + opacity: 0.45; +} + +/* ---------- Interaktion (nur Spielfeld) ---------- */ + +.board-interactive .cell { + cursor: pointer; +} + +.board-interactive .cell:hover .cell-frame { + stroke: #fff7d6; + stroke-opacity: 0.8; +} + +.board-interactive:focus { + outline: none; +} + +.board-interactive:focus-visible { + outline: 4px solid var(--px-gold, #f2c14e); + outline-offset: 4px; +} + +.board-interactive:focus-within .cell.cursor .cell-frame { + stroke: #fff7d6; + stroke-dasharray: 12.5 6.25; +} + +.board-interactive:focus-within .cell.cursor.preview-valid .cell-frame, +.board-interactive .cell.preview-valid .cell-frame { + stroke: #9be15d; +} + +.board-interactive:focus-within .cell.cursor.preview-invalid .cell-frame, +.board-interactive .cell.preview-invalid .cell-frame { + stroke: #e05a4f; +} + +.board-interactive .cell.preview-invalid { + cursor: not-allowed; +} + +/* Gültige Felder: gestrichelter Pixel-Rahmen */ +.legal-mark { + fill: none; + stroke: transparent; + stroke-width: 6.25; + stroke-dasharray: 12.5 12.5; +} + +.cell.legal .legal-mark { + stroke: #fff7d6; + stroke-opacity: 0.85; +} + +.cell.legal.preview-valid .legal-mark { + stroke: transparent; +} + +/* Vorschau: Land, das die neue Figur beanspruchen würde */ +.zoi-preview-path { + fill-opacity: 0.35; + stroke-width: 6.25; + stroke-opacity: 0.9; + pointer-events: none; +} + +.board-overlay { + pointer-events: none; +} + +.ghost-token { + opacity: 0.6; +} + +.preview-badge rect { + fill: var(--px-parchment, #f4e4c1); + stroke: var(--px-ink, #2b1d14); + stroke-width: 6.25; +} + +.preview-badge text { + font-family: var(--font-pixel); + font-size: 28px; + font-weight: 700; + text-anchor: middle; + dominant-baseline: central; + fill: var(--px-ink, #2b1d14); +} + +.preview-badge.tone-gain text { + fill: #2f7d32; +} + +.preview-badge.tone-loss text { + fill: #a83232; +} + +.preview-badge.tone-warn rect { + fill: #f2c14e; +} + +.preview-badge .badge-warn { + color: #a83232; +} + +.tutorial-ring { + fill: none; + stroke: var(--px-gold, #f2c14e); + stroke-width: 9.375; + stroke-dasharray: 18.75 12.5; + pointer-events: none; + animation: tutorial-pulse 1.4s steps(2) infinite; +} + +@keyframes tutorial-pulse { + 50% { + stroke-opacity: 0.35; + } +} + +/* Platzier-Animation: kurzes „Hochhüpfen“ in Pixel-Schritten */ +.token-placed { + animation: token-hop 0.24s steps(3); +} + +@keyframes token-hop { + 0% { + transform: translateY(-18.75px); + } + 66% { + transform: translateY(-6.25px); + } +} + +@media (prefers-reduced-motion: reduce) { + .token-placed, + .tutorial-ring { + animation: none; + } +} + +/* ---------- Mini-Karten ---------- */ + +.board-static { + pointer-events: none; +} + +.board-mini .res-pts { + display: none; +} + +.board-mini .zone-borders { + stroke-width: 6.25; +} + +/* ---------- 1v1 / Computer: Brett gesperrt, solange der Gegner zieht ---------- */ + +.board-interactive.bot-turn .cell { + cursor: progress; +} + +/* Moor: das trockene Gras etwas sumpfiger einfärben */ +.cell-biome-swamp .cell-terrain { + filter: saturate(0.8) brightness(0.82) sepia(0.25) hue-rotate(35deg); +} + +.cell-waves { + fill: #b9f1ef; + fill-opacity: 0.8; + pointer-events: none; +} + +/* Aufsteigende Gold-Zahlen (in Pixel-Schritten, kein weiches Gleiten) */ +.board-floats { + pointer-events: none; +} + +.float-label { + font-family: var(--font-pixel); + font-size: 34px; + font-weight: 700; + text-anchor: middle; + stroke: var(--px-ink, #2b1d14); + stroke-width: 7; + paint-order: stroke; + animation: float-up 0.9s steps(6) forwards; +} + +.float-gain { + fill: var(--px-gold, #f2c14e); +} + +.float-loss { + fill: #ff8a7a; +} + +@keyframes float-up { + from { + transform: translateY(0); + opacity: 1; + } + 70% { + opacity: 1; + } + to { + transform: translateY(-50px); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .float-label { + animation: none; + display: none; + } +} + +[data-perf='low'] .float-label { + display: none; +} + +/* ---------- Regeln „realms“: Land, Banner, Eroberung ---------- */ + +/* Umkämpftes Land: beide Farben im Wechsel (Pixel-Streifen statt Mischfarbe) */ +.board .cell-zoi.zoi-both { + fill: url(#p-contested); + fill-opacity: 0.75; +} + +.region-banners { + pointer-events: none; +} + +.banner-pole { + fill: var(--px-wood-dark, #3f2716); + stroke: var(--px-ink, #2b1d14); + stroke-width: 3.125; +} + +.banner-cloth { + stroke: var(--px-ink, #2b1d14); + stroke-width: 3.125; + stroke-linejoin: miter; +} + +.banner-emblem { + fill: var(--px-parchment, #f4e4c1); + stroke: var(--px-ink, #2b1d14); + stroke-width: 3.125; +} + +.banner-emblem.emblem-gold { + fill: var(--px-gold, #f2c14e); +} + +.banner-emblem.emblem-dark { + fill: var(--px-ink, #2b1d14); +} + +.banner-none .banner-cloth { + fill: var(--px-parchment, #f4e4c1); +} + +.banner-p1 .banner-cloth { + fill: var(--player-1); +} + +.banner-p2 .banner-cloth { + fill: var(--player-2); +} + +.banner-raised { + animation: banner-raise 0.6s steps(4); + transform-box: fill-box; +} + +@keyframes banner-raise { + 0% { + translate: 0 25px; + } + 60% { + translate: 0 -6.25px; + } +} + +/* Vorschau: Felder, die der Zug dem Gegner abnehmen würde */ +.capture-preview-path { + fill: none; + stroke: var(--px-gold, #f2c14e); + stroke-width: 9.375; + stroke-dasharray: 18.75 9.375; + pointer-events: none; +} + +.float-land { + fill: #fff7d6; +} + +@media (prefers-reduced-motion: reduce) { + .banner-raised { + animation: none; + } +} + +[data-perf='low'] .banner-raised { + animation: none; +} + +/* Angreifbar (fremdes/umkämpftes Land): rote Strichelung und gekreuzte Schwerter oben rechts */ +.cell.legal.attackable .legal-mark { + stroke: #ff8a7a; + stroke-opacity: 1; +} + +.attack-mark { + color: #fff7d6; + pointer-events: none; + /* Pixel-Umriss: vier harte Schatten statt eines weichen */ + filter: drop-shadow(3px 0 0 var(--px-ink, #2b1d14)) drop-shadow(-3px 0 0 var(--px-ink, #2b1d14)) + drop-shadow(0 3px 0 var(--px-ink, #2b1d14)) drop-shadow(0 -3px 0 var(--px-ink, #2b1d14)); +} + +/* Eroberung: Feld blitzt in Pixel-Schritten auf */ +.captured-flash .cell-zoi { + animation: captured-flash 0.8s steps(4); +} + +@keyframes captured-flash { + 0%, + 50% { + fill: #fff7d6; + fill-opacity: 0.9; + } +} + +@media (prefers-reduced-motion: reduce) { + .captured-flash .cell-zoi { + animation: none; + } +} + +[data-perf='low'] .captured-flash .cell-zoi { + animation: none; +} + +/* ---------- Räuber & Nebel (Phase 13c) ---------- */ + +.board { + --bandit: #8e4fbf; +} + +.cell-zoi.zoi-bandit { + fill: var(--bandit); + fill-opacity: 0.55; +} + +.camp-token { + pointer-events: none; + filter: drop-shadow(0 4px 0 rgba(0, 0, 0, 0.35)); +} + +.camp-token.camp-fallen { + filter: grayscale(1) brightness(0.7); + opacity: 0.55; +} + +/* Nebel: dichte Wolke in Pixel-Streifen, verdeckt Gelände, Figuren und Schätze */ +.fog-cell { + fill: url(#p-fog); + pointer-events: none; +} + +.cell.fogged { + cursor: default; +} + +/* ---------- Lande mit Charakter (Phase 13f) ---------- */ + +.land-marks { + pointer-events: none; +} + +.land-mark { + filter: drop-shadow(0 3px 0 rgba(0, 0, 0, 0.3)); +} + +.land-mark.hidden-mark { + display: none; +} + +/* Karten-Beschriftung: kleine Pixelschrift mit dunklem Rand, wie auf einer alten Karte */ +.land-label { + font-family: var(--font-pixel); + font-size: 17px; + font-weight: 700; + text-anchor: middle; + fill: #fff7d6; + stroke: var(--px-ink, #2b1d14); + stroke-width: 5; + paint-order: stroke; + letter-spacing: 0.5px; + opacity: 0.92; +} diff --git a/src/engine/app/board/geometry.ts b/src/engine/app/board/geometry.ts new file mode 100644 index 0000000..5046a7e --- /dev/null +++ b/src/engine/app/board/geometry.ts @@ -0,0 +1,74 @@ +/** + * geometry.ts + * Reine Geometrie des SVG-Spielfelds (ohne DOM, testbar). + * Koordinaten: 100 Einheiten pro Feld, Ursprung oben links. + */ + +import { SIZE } from '../../core/Board'; + +export const CELL = 100; +export const BOARD_PX = SIZE * CELL; + +/** + * Kanten zwischen zwei Feldern als SVG-Pfad. + * border = true: nur Kanten zwischen verschiedenen Zonen (dicke „Puzzle“-Grenzen), + * border = false: nur Kanten innerhalb derselben Zone (feine Gitterlinien). + * Benachbarte Teilstücke auf derselben Linie werden zusammengefasst. + */ +export function edgePath(zone: ArrayLike, border: boolean, size: number = SIZE): string { + const parts: string[] = []; + const idx = (x: number, y: number) => y * size + x; + const pick = (a: number, b: number) => (zone[a] !== zone[b]) === border; + + // Senkrechte Kanten: zwischen Spalte x und x+1 + for (let x = 0; x < size - 1; x++) { + let start = -1; + for (let y = 0; y <= size; y++) { + const on = y < size && pick(idx(x, y), idx(x + 1, y)); + if (on && start < 0) start = y; + if (!on && start >= 0) { + parts.push(`M${(x + 1) * CELL} ${start * CELL}V${y * CELL}`); + start = -1; + } + } + } + // Waagerechte Kanten: zwischen Zeile y und y+1 + for (let y = 0; y < size - 1; y++) { + let start = -1; + for (let x = 0; x <= size; x++) { + const on = x < size && pick(idx(x, y), idx(x, y + 1)); + if (on && start < 0) start = x; + if (!on && start >= 0) { + parts.push(`M${start * CELL} ${(y + 1) * CELL}H${x * CELL}`); + start = -1; + } + } + } + return parts.join(''); +} + +/** Gesamtlänge eines edgePath in Feldkanten (für Tests). */ +export function edgeLength(path: string): number { + let total = 0; + for (const m of path.matchAll(/M(\d+) (\d+)([VH])(\d+)/g)) { + const from = m[3] === 'V' ? Number(m[2]) : Number(m[1]); + total += (Number(m[4]) - from) / CELL; + } + return total; +} + +/** Höhe des Punkte-Badges der Zugvorschau */ +export const BADGE_HEIGHT = 46; + +/** + * Lage des Punkte-Badges (linke obere Ecke) zur Vorschau auf Feld `cell`: über dem Feld, in der obersten Reihe + * darunter, waagerecht innerhalb des Bretts – sonst schneidet die viewBox es ab (Issue #87, größere Karten). + */ +export function badgeOrigin(cell: number, size: number, width: number): { x: number; y: number } { + const x = (cell % size) * CELL; + const y = Math.floor(cell / size) * CELL; + return { + x: Math.min(Math.max(x + CELL / 2 - width / 2, 0), size * CELL - width), + y: y === 0 ? CELL - 4 : y - BADGE_HEIGHT + 2, + }; +} diff --git a/src/engine/app/env.ts b/src/engine/app/env.ts new file mode 100644 index 0000000..e01b69a --- /dev/null +++ b/src/engine/app/env.ts @@ -0,0 +1,41 @@ +/** + * env.ts + * Wo das Spiel läuft (Issue #68): auf der eigenen Domain (/play/) oder als eigenständige Fassung (itch.io, Desktop, + * Apps – Vite-Modus „portable“, gebaut von scripts/build-portable.mjs). Alles, was davon abhängt, geht hier durch: + * Server-Adresse für API und WebSocket, Pfade zu Sprites und Musik, Links auf Website, Leitfaden und Feedback. + */ + +/** Eigenständige Fassung statt der eigenen Domain */ +export const PORTABLE = import.meta.env.MODE === 'portable'; + +/** Server der Online-Funktionen; leer = gleiche Herkunft (Web) */ +export const API_ORIGIN = PORTABLE ? __API_ORIGIN__ : ''; + +/** Öffentliche Seite (Website, Leitfaden, Roadmap, Feedback, Rechtliches) – im Web relativ, sonst absolut */ +export function siteUrl(path: string): string { + return PORTABLE ? __API_ORIGIN__ + path : path; +} + +/** Datei aus public/ (Sprites, Musik): im Web ab der Wurzel, eigenständig relativ zur Seite */ +export function assetUrl(path: string): string { + return import.meta.env.BASE_URL + path.replace(/^\//, ''); +} + +/** + * Unterprotokolle des WebSockets: die eigenständige Fassung meldet sich mit ihrem Token an (Issue #69) – ein + * Browser-WebSocket kann keine eigenen Kopfzeilen schicken. Web: keine (Cookie). + */ +export function wsProtocols(token: string | null): string[] | undefined { + return PORTABLE && token ? ['ninelands', `auth.${token}`] : undefined; +} + +/** WebSocket der Online-Duelle und der Freunde-Lobby */ +export function wsUrl(path: string): string { + if (PORTABLE) return __API_ORIGIN__.replace(/^http/, 'ws') + path; + return `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}`; +} + +/** Grundlage für Teilen- und Einladungslinks: immer das Spiel auf der eigenen Domain */ +export function gameUrl(): string { + return PORTABLE ? `${__API_ORIGIN__}/play/` : location.href; +} diff --git a/src/engine/tsconfig.json b/src/engine/tsconfig.json index 46845d5..d0abff1 100644 --- a/src/engine/tsconfig.json +++ b/src/engine/tsconfig.json @@ -3,12 +3,18 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "lib": ["ES2023", "WebWorker"], + "lib": ["ES2023", "DOM"], // duel.ts nutzt Parameter-Properties; esbuild übersetzt sie, `node --experimental-strip-types` nicht "erasableSyntaxOnly": false, "strict": true, "noUncheckedIndexedAccess": false, "resolveJsonModule": true }, - "include": ["core/**/*.ts", "campaign/*.json", "puzzles/*.json"] + "include": [ + "core/**/*.ts", + "app/**/*.ts", + "campaign/*.json", + "puzzles/*.json", + "../types/*.d.ts" + ] } diff --git a/src/types/ninelands-env.d.ts b/src/types/ninelands-env.d.ts new file mode 100644 index 0000000..9e379e5 --- /dev/null +++ b/src/types/ninelands-env.d.ts @@ -0,0 +1,11 @@ +// Typen für die Brett-Darstellung aus Ninelands (src/engine/app): dort liefert Vite import.meta.env und +// __API_ORIGIN__; hier setzt esbuild die Werte per --define (package.json, build:client). +interface ImportMetaEnv { + readonly MODE: string + readonly BASE_URL: string +} +interface ImportMeta { + readonly env: ImportMetaEnv +} +declare const __API_ORIGIN__: string +declare module '*.css'