feat(client): Startbildschirm, Spielansicht und Ergebniskarte mit dem Brett aus Ninelands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 16:37:40 +01:00
parent 60debf0a7a
commit bf6bdf9c2d
27 changed files with 3085 additions and 61 deletions

2
.gitignore vendored
View file

@ -3,3 +3,5 @@
/public/*.js*
/.env
/rules/
/public/*.css*
/public/fonts/

23
PLAN.md
View file

@ -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.

View file

@ -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,

13
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -2,36 +2,27 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, maximum-scale=1, minimum-scale=1, user-scalable=no"
>
<title>Game</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ninelands</title>
<link rel="stylesheet" href="game.css">
<link rel="modulepreload" href="game.js">
<style>
body {
margin: 0;
width: 100dvw;
height: 100dvh;
overflow: hidden;
display: grid;
place-items: center;
}
main {
display: grid;
gap: 1rem;
justify-items: center;
}
</style>
</head>
<body>
<main>
<div class="counter">
<button id="dec-btn" type="button">-</button>
<output id="counter">0</output>
<button id="inc-btn" type="button">+</button>
</div>
</main>
<!-- Symbole für die Brett-Darstellung (BoardView nutzt <use href="#i-warn">) -->
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
<symbol id="i-warn" viewBox="0 0 24 24">
<g
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3 2 20h20zM12 10v4M12 17v.5" />
</g>
</symbol>
</svg>
<div id="app"></div>
<script src="game.js" type="module"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View file

@ -3,29 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Splash</title>
<title>Ninelands</title>
<link rel="stylesheet" href="splash.css">
<link rel="modulepreload" href="splash.js">
<style>
body {
margin: 0;
width: 100dvw;
height: 100dvh;
overflow: hidden;
display: grid;
place-items: center;
}
main {
display: grid;
gap: 1rem;
justify-items: center;
}
</style>
</head>
<body>
<main>
<img src="/snoo.png" width="201" height="200" alt="Snoo">
<button id="start-btn" type="button">Start</button>
</main>
<div id="app"></div>
<script src="splash.js" type="module"></script>
</body>
</html>

View file

@ -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 <tag> [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)

30
src/client/api.ts Normal file
View file

@ -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<T>(endpoint: Endpoint, body?: unknown): Promise<T> {
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<GetDayRsp> =>
call<GetDayRsp>(Endpoint.GetDay)
export const submitGame = (req: SubmitReq): Promise<SubmitRsp> =>
call<SubmitRsp>(Endpoint.Submit, req)

70
src/client/audio.ts Normal file
View file

@ -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()
})
}
}

26
src/client/dom.ts Normal file
View file

@ -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<K extends keyof HTMLElementTagNameMap>(
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<T extends HTMLElement>(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))
}

183
src/client/game.css Normal file
View file

@ -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;
}

View file

@ -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<HTMLDivElement>('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<UnitType, HTMLButtonElement>()
view.listen({click: onCellClick, hover: onHover})
undoBtn.addEventListener('click', undo)
async function main(): Promise<void> {
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<void> {
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()

30
src/client/result.ts Normal file
View file

@ -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
}

97
src/client/splash.css Normal file
View file

@ -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;
}
}

View file

@ -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<HTMLDivElement>('app')
async function main(): Promise<void> {
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()

219
src/client/style.css Normal file
View file

@ -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;
}

121
src/client/texts.ts Normal file
View file

@ -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<UnitType, string> = {
HIVEMOTHER: 'Castle',
DRONE: 'Knight',
HIVEWORKER: 'Farmer',
HIVESCOUT: 'Bishop',
}
export const UNIT_DESC: Record<UnitType, string> = {
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<string, string>
)[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)
}

View file

@ -5,5 +5,6 @@
"lib": ["DOM", "ES2023"],
"customConditions": ["browser"]
},
"include": ["**/*.ts", "../types/*.d.ts"],
"references": [{"path": "../shared/"}, {"path": "../engine/"}]
}

View file

@ -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<string, string | number>;
function svgEl<K extends keyof SVGElementTagNameMap>(
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<string>([
...[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<SVGImageElement>('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<Banner, { cloth: string; emblem?: string }> = {
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<UnitType, string> = {
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<UnitType, string> = {
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<number, SVGImageElement>();
/** 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);
}

View file

@ -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/<name>.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`);

View file

@ -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;
}

View file

@ -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<number>, 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,
};
}

41
src/engine/app/env.ts Normal file
View file

@ -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;
}

View file

@ -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"
]
}

11
src/types/ninelands-env.d.ts vendored Normal file
View file

@ -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'