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:
parent
60debf0a7a
commit
bf6bdf9c2d
27 changed files with 3085 additions and 61 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,3 +3,5 @@
|
|||
/public/*.js*
|
||||
/.env
|
||||
/rules/
|
||||
/public/*.css*
|
||||
/public/fonts/
|
||||
|
|
|
|||
23
PLAN.md
23
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.
|
||||
|
|
|
|||
10
biome.jsonc
10
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,
|
||||
|
|
|
|||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
BIN
public/snoo.png
BIN
public/snoo.png
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
30
src/client/api.ts
Normal 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
70
src/client/audio.ts
Normal 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
26
src/client/dom.ts
Normal 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
183
src/client/game.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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
30
src/client/result.ts
Normal 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
97
src/client/splash.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
219
src/client/style.css
Normal 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
121
src/client/texts.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -5,5 +5,6 @@
|
|||
"lib": ["DOM", "ES2023"],
|
||||
"customConditions": ["browser"]
|
||||
},
|
||||
"include": ["**/*.ts", "../types/*.d.ts"],
|
||||
"references": [{"path": "../shared/"}, {"path": "../engine/"}]
|
||||
}
|
||||
|
|
|
|||
901
src/engine/app/board/BoardView.ts
Normal file
901
src/engine/app/board/BoardView.ts
Normal 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);
|
||||
}
|
||||
94
src/engine/app/board/atlas.ts
Normal file
94
src/engine/app/board/atlas.ts
Normal 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`);
|
||||
560
src/engine/app/board/board.css
Normal file
560
src/engine/app/board/board.css
Normal 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;
|
||||
}
|
||||
74
src/engine/app/board/geometry.ts
Normal file
74
src/engine/app/board/geometry.ts
Normal 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
41
src/engine/app/env.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
11
src/types/ninelands-env.d.ts
vendored
Normal 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'
|
||||
Loading…
Reference in a new issue