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 5d90556..0000000 Binary files a/public/snoo.png and /dev/null differ 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'