diff --git a/PLAN.md b/PLAN.md
index b77f054..cddcc65 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -268,3 +268,19 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game
Dezember startet. Die Karte hängt weiter nur am Datum (alle spielen am selben Tag dieselbe). `FIRST_DAY` entfällt.
- Plan Besitzer/Claude: `r/ninelands_game_dev` bleibt Test-Subreddit; `r/Ninelands` (Name frei) etwa eine Woche vor
der Abgabe anlegen (≈ 1. Dez.), öffentlich oder „Restricted“, < 200 Mitglieder bis zur Bewertung.
+
+### 2026-11-13 – Trailer, Galerie, Vimeo-Cover
+
+- `npm run capture:trailer` (`scripts/capture/trailer.mjs`, Bühne `stage.html/.ts/.css`, Partie `plan.ts`): baut den
+ Client, startet eine API-Attrappe und lässt das **echte** Spiel (`public/`) in einer Bühne laufen – Untertitel links,
+ Handy (420 × 840, skaliert) rechts, ein „Finger“ zeigt jeden Tipp. Der Bot (`smart`) spielt die Karte vom 2026-10-10;
+ die Partie wird beim besten Stand abgeschnitten und per „End game“ beendet (zeigt die Funktion gleich mit).
+ Ergebnis: `trailer.mp4` (1920 × 1080, ≈ 47 s, Musik mit Ein-/Ausblenden), `vimeo-cover.png`, `gallery/*.png` (3:2).
+- Szenen laufen dreimal: Video mit Pausen, dann ohne Pausen für Galerie (1800 × 1200) und Cover (1920 × 1080).
+- **Fallstricke:** `UNIT_TYPES` ist `Burg, Ritter, Bauer, Läufer` – die Knopf-Reihenfolge kommt deshalb aus der Engine,
+ nicht von Hand. Pixelify Sans verbindet „fi“ zu einem Zeichen, das wie „A“ aussieht („Arst“) → auf der Bühne
+ `font-variant-ligatures: none`. „Play“ im Startbildschirm ruft `requestExpandedMode` (gibt es außerhalb von Reddit
+ nicht) → nur angetippt, die Bühne wechselt selbst ins Spiel. Playwright-Chromium spielt kein H.264 → eigene
+ Aufnahmen werden vorher nach VP9/WebM umgewandelt.
+- Reddit selbst (Feed, Kommentar im Thread, Flair) nimmt der Besitzer mit seinem Konto auf (Reddit blockt
+ Automatisierung); optional als `dist/trailer/input/feed.*` / `comment.*`.
diff --git a/docs/submission.md b/docs/submission.md
index 2dbbb76..04ff02a 100644
--- a/docs/submission.md
+++ b/docs/submission.md
@@ -137,6 +137,36 @@ devvit-journeys, esbuild, biome, playwright, ffmpeg, pixel-art
- Original-Spiel: `https://ninelands.stackteam.at`
- Code: Repo ist privat – nur eintragen, wenn es öffentlich wird (optional)
+### Devpost – Project media und Video
+
+Erzeugen mit `npm run capture:trailer` → `dist/marketing/` (nicht im Repo, ≈ 2 min). Das echte Spiel läuft in einer
+Bühne (links Untertitel, rechts Handy), API als Attrappe, ein Bot spielt die Karte vom 2026-10-10 (3 Sterne, beendet
+per „End game“). Leaderboard-Namen sind erfunden.
+
+| Datei | Wofür | Vorgabe | Stand |
+| :-------------------- | :------------------------------------------- | :--------------------------------------- | :---------------------------- |
+| `trailer.mp4` | Video (Vimeo/YouTube, Devpost-Link) | < 1 min, öffentlich | 1920×1080, ≈ 47 s, ≈ 9 MB |
+| `vimeo-cover.png` | Vorschaubild (Thumbnail) auf Vimeo | 16:9, 1920×1080 | Logo + Brett mit 3 Sternen |
+| `gallery/00…06-*.png` | Devpost „Image gallery“ (Reihenfolge = Name) | JPG/PNG/GIF, ≤ 5 MB, 3:2 empfohlen, ≤ 15 | 7 Bilder, 1800×1200, ≤ 150 KB |
+
+Galerie: 00 Titel, 01 Tagespost, 02 Land beanspruchen (Vorschau), 03 drei Sterne, 04 Ergebniskarte mit Bestenliste
+und Freischaltung, 05 Realm, 06 Schluss. Ergänzen lassen sich echte Reddit-Screenshots (Kommentar im Thread, Flair).
+
+**Eigene Reddit-Aufnahmen (optional):** hochkant (Handy oder Handy-Ansicht im Browser) als
+`dist/trailer/input/feed.mp4` (Tagespost im Feed, ≤ 8 s) und `dist/trailer/input/comment.mp4` (der Ergebnis-Kommentar
+im Thread, ≤ 8 s) ablegen (auch `.mov`/`.webm`), dann `npm run capture:trailer` neu laufen lassen – sie erscheinen im
+Handy an passender Stelle. Keine aktuelle Tageskarte zeigen (Spoiler) und keine fremden Nutzernamen.
+
+**Vimeo-Beschreibung** (Musik ist CC BY-SA 4.0, Nennung nötig):
+
+```text
+Ninelands – a daily strategy game on Reddit (Devvit Web). A new 9×9 map every day, one shared leaderboard per
+subreddit, streaks, unlockable realms and your result in the comments. Port of Ninelands (ninelands.stackteam.at).
+
+Music: "Dreamscape (part I)" by Vee Ray, CC BY-SA 4.0 (re-encoded).
+Graphics: "Mini World Sprites" by Shade and octoshrimpy, CC0. Font: Pixelify Sans, SIL OFL 1.1.
+```
+
### Noch zu schreiben
- **Portierung angeben** (Kategorie „Best Ported Daily Game“): Ninelands gehört StackTeam; erklären, was seit dem
diff --git a/package.json b/package.json
index f612f9b..de42295 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,8 @@
"test:unit": "esbuild --bundle --log-level=warning --platform=node --format=esm --packages=external --outdir=dist/test --outbase=src src/**/*.test.ts && node --test dist/test/**/*.test.js",
"publish": "npm run clean && npm run build && devvit publish",
"watch": "sh -c 'trap \"kill 0\" exit; npm run build:client -- --watch=forever& npm run build:server -- --watch=forever& wait' --",
- "capture": "node scripts/capture/record.mjs"
+ "capture": "node scripts/capture/record.mjs",
+ "capture:trailer": "node scripts/capture/trailer.mjs"
},
"devDependencies": {
"@biomejs/biome": "2.6.6",
diff --git a/scripts/capture/plan.ts b/scripts/capture/plan.ts
new file mode 100644
index 0000000..4ac65a1
--- /dev/null
+++ b/scripts/capture/plan.ts
@@ -0,0 +1,58 @@
+// Partie für Trailer und Galerie vorab berechnen (läuft in Node, gebündelt von trailer.mjs).
+// Der Bot spielt die Tageskarte; danach wird die Partie beim besten Stand abgeschnitten (wie „End game“).
+import {BOTS} from '../../src/engine/core/bots.ts'
+import {Game} from '../../src/engine/core/Game.ts'
+import {starsOf} from '../../src/engine/core/goals.ts'
+import {mulberry32} from '../../src/engine/core/rng.ts'
+import type {UnitType} from '../../src/engine/core/units.ts'
+
+export {UNIT_TYPES} from '../../src/engine/core/units.ts'
+
+import {goalsOf} from '../../src/server/verify.ts'
+import {dailyBoard, dailyMap} from '../../src/shared/daily.ts'
+
+export interface Plan {
+ day: string
+ seed: number
+ trait: string
+ goals: {gold: number; lands: number}
+ /** net = Gold-Saldo des Zugs (für die Wahl des Galeriebilds) */
+ moves: {unit: UnitType; cell: number; net: number}[]
+ stars: number
+ gold: number
+ lands: number
+ /** Partie endet von selbst (kein Zug mehr) statt per „End game“ */
+ natural: boolean
+}
+
+export function plan(day: string, seed: number): Plan {
+ const map = dailyMap(new Date(`${day}T00:00:00Z`))
+ const goals = goalsOf(map)
+ const game = new Game(dailyBoard(map), {undoLimit: 0})
+ const rng = mulberry32(seed)
+ const all: Plan['moves'] = []
+ let best = {len: 0, key: -1}
+ while (!game.isGameOver()) {
+ const m = BOTS.smart(game, rng)
+ const before = game.score
+ game.place(m.unit, m.cell)
+ all.push({unit: m.unit, cell: m.cell, net: game.score - before})
+ const s = starsOf(game, goals)
+ const key = s.count * 100_000 + game.score
+ if (key >= best.key) best = {len: all.length, key}
+ }
+ const moves = all.slice(0, best.len)
+ const end = new Game(dailyBoard(map), {undoLimit: 0})
+ for (const m of moves) end.place(m.unit, m.cell)
+ return {
+ day,
+ seed,
+ trait: map.trait,
+ goals: {gold: goals.gold, lands: goals.lands},
+ moves,
+ stars: starsOf(end, goals).count,
+ gold: end.score,
+ lands: end.regionCounts()[0] ?? 0,
+ natural: end.isGameOver(),
+ }
+}
diff --git a/scripts/capture/stage.css b/scripts/capture/stage.css
new file mode 100644
index 0000000..cb6f904
--- /dev/null
+++ b/scripts/capture/stage.css
@@ -0,0 +1,173 @@
+/* Bühne für Trailer und Galerie: links Untertitel, rechts das Spiel im Handyformat (420 × 840, skaliert). */
+@import '@fontsource/pixelify-sans/latin-700.css';
+
+:root {
+ --ink: #2b1d14;
+ --wood: #5b3a22;
+ --wood-dark: #3f2716;
+ --parchment: #f4e4c1;
+ --gold: #f2c14e;
+ --gold-dark: #b8841c;
+ --grass-dark: #2c4220;
+ --s: 1.15;
+}
+
+html,
+body {
+ margin: 0;
+ height: 100%;
+}
+
+body {
+ background-color: var(--grass-dark);
+ background-image: conic-gradient(
+ rgba(0, 0, 0, 0.1) 25%,
+ transparent 0 50%,
+ rgba(0, 0, 0, 0.1) 0 75%,
+ transparent 0
+ );
+ background-size: 8px 8px;
+ color: var(--parchment);
+ font-family: 'Pixelify Sans', monospace;
+ /* sonst wird „fi“ in Pixelify zu einem Zeichen, das wie „A“ aussieht */
+ font-variant-ligatures: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6vw;
+ overflow: hidden;
+}
+
+#text {
+ width: 40vw;
+ transition: opacity 0.25s;
+}
+
+#text.fade {
+ opacity: 0;
+}
+
+#title {
+ margin: 0 0 0.3em;
+ font-size: 5.2vh;
+ line-height: 1.1;
+ color: var(--gold);
+ text-shadow: 0.35vh 0.35vh 0 var(--ink);
+}
+
+#title.logo {
+ font-size: 10vh;
+ letter-spacing: 0.03em;
+}
+
+#sub {
+ margin: 0;
+ font-size: 3.2vh;
+ line-height: 1.3;
+ text-shadow: 0.25vh 0.25vh 0 var(--ink);
+}
+
+#foot {
+ margin: 1.4em 0 0;
+ font-size: 2.2vh;
+ opacity: 0.8;
+}
+
+#phone {
+ flex: none;
+ width: calc(420px * var(--s));
+ height: calc(840px * var(--s));
+ border: 10px solid var(--ink);
+ box-shadow:
+ 0 0 0 4px var(--wood),
+ 12px 12px 0 rgba(0, 0, 0, 0.4);
+ background: var(--grass-dark);
+ overflow: hidden;
+ transition: height 0.3s ease-out;
+}
+
+/* Post im Feed: nur die Karte (Kopfzeile + 512 px Inline-Post), kein leeres Handy */
+body.post-mode #phone {
+ height: calc(552px * var(--s));
+}
+
+#screen {
+ position: relative;
+ width: 420px;
+ height: 840px;
+ transform: scale(var(--s));
+ transform-origin: 0 0;
+}
+
+#screen > * {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ display: none;
+}
+
+#screen > .on {
+ display: block;
+}
+
+#post.on {
+ display: flex;
+ flex-direction: column;
+ background: var(--ink);
+}
+
+.post-head {
+ margin: 0;
+ height: 40px;
+ line-height: 40px;
+ padding: 0 12px;
+ font-size: 16px;
+ color: var(--gold);
+}
+
+#splash {
+ flex: none;
+ width: 420px;
+ height: 512px;
+ border: 0;
+}
+
+#clip {
+ object-fit: cover;
+ background: #000;
+}
+
+#finger {
+ position: fixed;
+ width: 44px;
+ height: 44px;
+ margin: -22px 0 0 -22px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.35);
+ border: 3px solid rgba(255, 255, 255, 0.85);
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+ opacity: 0;
+ transition:
+ left 0.22s ease-out,
+ top 0.22s ease-out,
+ opacity 0.2s;
+ z-index: 10;
+}
+
+#finger.on {
+ opacity: 1;
+}
+
+#finger.press {
+ animation: press 0.25s ease-out;
+}
+
+@keyframes press {
+ 50% {
+ transform: scale(0.7);
+ background: rgba(242, 193, 78, 0.7);
+ }
+}
diff --git a/scripts/capture/stage.html b/scripts/capture/stage.html
new file mode 100644
index 0000000..3d17d38
--- /dev/null
+++ b/scripts/capture/stage.html
@@ -0,0 +1,27 @@
+
+
+
+
+ Ninelands trailer
+
+
+
+
+
+
+
+
r/Ninelands · daily post
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/capture/stage.ts b/scripts/capture/stage.ts
new file mode 100644
index 0000000..98f6080
--- /dev/null
+++ b/scripts/capture/stage.ts
@@ -0,0 +1,90 @@
+// Bühne für Trailer und Galerie (scripts/capture/trailer.mjs steuert sie über window.stage).
+import './stage.css'
+
+const $ = (id: string) =>
+ document.getElementById(id) as T
+const text = $('text')
+const title = $('title')
+const sub = $('sub')
+const foot = $('foot')
+const finger = $('finger')
+const splash = $('splash')
+const game = $('game')
+const clip = $('clip')
+const post = $('post')
+
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
+
+/** Handy so groß wie möglich (88 % der Höhe) */
+function fit(): void {
+ document.documentElement.style.setProperty(
+ '--s',
+ String((innerHeight * 0.88) / 840),
+ )
+}
+fit()
+addEventListener('resize', fit)
+
+function load(frame: HTMLIFrameElement, src: string): Promise {
+ return new Promise(resolve => {
+ frame.addEventListener('load', () => resolve(), {once: true})
+ frame.src = src
+ })
+}
+
+declare global {
+ interface Window {
+ stage: typeof stage
+ }
+}
+
+const stage = {
+ async caption(
+ t: string,
+ s = '',
+ f = '',
+ opts: {logo?: boolean; instant?: boolean} = {},
+ ) {
+ if (!opts.instant && title.textContent) {
+ text.classList.add('fade')
+ await sleep(250)
+ }
+ title.textContent = t
+ title.classList.toggle('logo', Boolean(opts.logo))
+ sub.textContent = s
+ foot.textContent = f
+ text.classList.remove('fade')
+ },
+ /** Was das Handy zeigt: Post im Feed, Spiel (Vollbild) oder eine eigene Aufnahme */
+ async show(mode: 'post' | 'game' | 'clip', src?: string) {
+ if (mode === 'post' && src) await load(splash, src)
+ if (mode === 'game' && src) await load(game, src)
+ if (mode === 'clip' && src) {
+ clip.src = src
+ await clip.play()
+ }
+ for (const [el, m] of [
+ [post, 'post'],
+ [game, 'game'],
+ [clip, 'clip'],
+ ] as const)
+ el.classList.toggle('on', m === mode)
+ document.body.classList.toggle('post-mode', mode === 'post')
+ // Schriften im Spiel abwarten
+ await (mode === 'post' ? splash : game).contentDocument?.fonts.ready
+ },
+ clipDuration: () => clip.duration,
+ finger(x: number | null, y = 0) {
+ finger.classList.toggle('on', x !== null)
+ if (x !== null) {
+ finger.style.left = `${x}px`
+ finger.style.top = `${y}px`
+ }
+ },
+ press() {
+ finger.classList.remove('press')
+ void finger.offsetWidth
+ finger.classList.add('press')
+ },
+}
+window.stage = stage
diff --git a/scripts/capture/trailer.mjs b/scripts/capture/trailer.mjs
new file mode 100644
index 0000000..a541ab8
--- /dev/null
+++ b/scripts/capture/trailer.mjs
@@ -0,0 +1,487 @@
+#!/usr/bin/env node
+// Trailer (Vimeo/Devpost), Galerie-Bilder (3:2) und Vimeo-Cover aufnehmen.
+// Das echte Spiel (public/ nach build:client) läuft in einer Bühne: links Untertitel, rechts das Handy.
+// Die API ist eine Attrappe; ein Bot spielt die Karte vom 2026-10-10 (vor dem ersten Spieltag, verrät nichts).
+// Eigene Reddit-Aufnahmen (optional) in dist/trailer/input/: feed.* (Post im Feed), comment.* (Kommentar im
+// Thread) – mp4/mov/webm, hochkant; sie werden an passender Stelle im Handy gezeigt.
+// Aufruf: npm run capture:trailer → dist/marketing/trailer.mp4, vimeo-cover.png, gallery/*.png
+import {execFileSync} from 'node:child_process'
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ statSync,
+} from 'node:fs'
+import {createServer} from 'node:http'
+import {dirname, extname, join, resolve} from 'node:path'
+import {fileURLToPath, pathToFileURL} from 'node:url'
+import {build} from 'esbuild'
+import {chromium} from 'playwright'
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
+const work = join(root, 'dist/trailer')
+const site = join(work, 'site')
+const input = join(work, 'input')
+const out = join(root, 'dist/marketing')
+const gallery = join(out, 'gallery')
+rmSync(site, {recursive: true, force: true})
+rmSync(gallery, {recursive: true, force: true})
+for (const d of [site, input, out, gallery]) mkdirSync(d, {recursive: true})
+
+const DAY = '2026-10-10'
+const SEED = 1
+
+const ffmpeg = (...args) =>
+ execFileSync('ffmpeg', ['-v', 'error', '-y', ...args])
+
+// 1. Spiel bauen, Bühne und Partie-Planer bündeln
+execFileSync('npm', ['run', 'build:client'], {cwd: root, stdio: 'inherit'})
+await build({
+ entryPoints: [join(root, 'scripts/capture/stage.ts')],
+ bundle: true,
+ format: 'esm',
+ outdir: site,
+ logLevel: 'warning',
+ loader: {'.woff2': 'file', '.woff': 'file'},
+ assetNames: 'fonts/[name]-[hash]',
+})
+copyFileSync(join(root, 'scripts/capture/stage.html'), join(site, 'stage.html'))
+await build({
+ entryPoints: [join(root, 'scripts/capture/plan.ts')],
+ bundle: true,
+ platform: 'node',
+ format: 'esm',
+ outfile: join(work, 'plan.mjs'),
+ logLevel: 'warning',
+})
+const {plan, UNIT_TYPES} = await import(
+ pathToFileURL(join(work, 'plan.mjs')).href
+)
+const p = plan(DAY, SEED)
+// Reihenfolge der Figuren-Knöpfe im Spiel
+const UNIT_ORDER = UNIT_TYPES
+console.log(
+ `Partie ${DAY}/${SEED}: ${p.moves.length} Züge, ${p.stars}★, ${p.gold} Gold, ${p.lands} Länder`,
+)
+
+// 2. Eigene Aufnahmen für Chromium umwandeln (Playwright-Chromium kann kein H.264)
+const clips = {}
+for (const name of ['feed', 'comment']) {
+ const src = readdirSync(input).find(f => f.startsWith(`${name}.`))
+ if (!src) continue
+ mkdirSync(join(site, 'clips'), {recursive: true})
+ ffmpeg(
+ '-i',
+ join(input, src),
+ '-t',
+ '8',
+ '-vf',
+ 'scale=840:-2',
+ '-an',
+ '-c:v',
+ 'libvpx-vp9',
+ '-b:v',
+ '3M',
+ join(site, 'clips', `${name}.webm`),
+ )
+ clips[name] = `/clips/${name}.webm`
+}
+
+// 3. API-Attrappe: Tag, Abgabe, Aussehen, Kommentar
+const top = [
+ {rank: 1, name: 'meadow_warden', stars: 3, gold: p.gold + 6, lands: 9},
+ {rank: 2, name: 'you', stars: p.stars, gold: p.gold, lands: p.lands},
+ {rank: 3, name: 'orcish_accountant', stars: 2, gold: p.gold - 9, lands: 8},
+ {rank: 4, name: 'quietfarmer', stars: 2, gold: p.gold - 14, lands: 9},
+ {rank: 5, name: 'ninefold', stars: 1, gold: p.gold - 30, lands: 7},
+]
+let state
+function reset() {
+ state = {
+ day: DAY,
+ number: 12,
+ mapId: 'TRAILER',
+ trait: p.trait,
+ goals: p.goals,
+ today: true,
+ loggedIn: true,
+ mine: null,
+ stats: {streak: 6, bestStreak: 6, played: 11, totalStars: 18},
+ top: top.filter(r => r.name !== 'you').map((r, i) => ({...r, rank: i + 1})),
+ players: 40,
+ look: {
+ color: 'purple',
+ faction: 'kingdom',
+ theme: 'autumn',
+ banner: 'pennant',
+ },
+ commented: false,
+ }
+}
+reset()
+const types = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.png': 'image/png',
+ '.mp3': 'audio/mpeg',
+ '.webm': 'video/webm',
+ '.woff2': 'font/woff2',
+ '.woff': 'font/woff',
+}
+const json = (res, body) => {
+ res.writeHead(200, {'Content-Type': 'application/json'})
+ res.end(JSON.stringify(body))
+}
+const body = async req => {
+ let b = ''
+ for await (const c of req) b += c
+ return b ? JSON.parse(b) : {}
+}
+const server = createServer(async (req, res) => {
+ const path = decodeURIComponent((req.url ?? '/').split('?')[0])
+ if (path === '/api/day') return json(res, state)
+ if (path === '/api/look') {
+ state.look = (await body(req)).look
+ return json(res, {look: state.look})
+ }
+ if (path === '/api/comment') {
+ state.commented = true
+ return json(res, {posted: true})
+ }
+ if (path === '/api/submit') {
+ await body(req)
+ const mine = {stars: p.stars, gold: p.gold, lands: p.lands, rank: 2}
+ state.mine = mine
+ state.top = top
+ state.players = 41
+ state.stats = {
+ streak: 7,
+ bestStreak: 7,
+ played: 12,
+ totalStars: 18 + p.stars,
+ }
+ return json(res, {
+ counted: true,
+ result: mine,
+ played: {stars: p.stars, gold: p.gold, lands: p.lands},
+ stats: state.stats,
+ top,
+ players: 41,
+ })
+ }
+ if (path.startsWith('/api/')) return json(res, {})
+ for (const dir of [site, join(root, 'public')]) {
+ const file = join(dir, path === '/' ? 'stage.html' : path)
+ if (file.startsWith(dir) && isFile(file)) {
+ res.writeHead(200, {
+ 'Content-Type': types[extname(file)] ?? 'application/octet-stream',
+ })
+ res.end(readFileSync(file))
+ return
+ }
+ }
+ res.writeHead(404)
+ res.end()
+})
+function isFile(f) {
+ try {
+ return statSync(f).isFile()
+ } catch {
+ return false
+ }
+}
+await new Promise(r => server.listen(0, '127.0.0.1', r))
+const base = `http://127.0.0.1:${server.address().port}/`
+
+// 4. Szenen – dieselben für Video (mit Pausen) und Standbilder (snap)
+/**
+ * @param {import('playwright').Page} page
+ * @param {{fast: boolean, snap: (name: string) => Promise}} o
+ */
+async function scenes(page, o) {
+ const wait = ms => (o.fast ? Promise.resolve() : page.waitForTimeout(ms))
+ const stage = (fn, ...args) =>
+ page.evaluate(([f, a]) => window.stage[f](...a), [fn, args])
+ const frameOf = name => page.frame({url: u => u.pathname.endsWith(name)})
+ /** Finger zum Element, tippen, klicken */
+ async function tap(locator, hoverMs = 350, click = true) {
+ const box = await locator.boundingBox()
+ if (box)
+ await stage('finger', box.x + box.width / 2, box.y + box.height / 2)
+ await locator.hover()
+ await wait(hoverMs)
+ await stage('press')
+ if (click) await locator.click()
+ }
+ // Galeriebild „Land beanspruchen“: Vorschau des einträglichsten Zugs ab Zug 6
+ let snapAt = 6
+ for (let i = 6; i < p.moves.length - 2; i++)
+ if (p.moves[i].net > p.moves[snapAt].net) snapAt = i
+
+ reset()
+ await page.goto(base)
+ await page.evaluate(() => document.fonts.ready)
+ await stage('show', 'post', '/splash.html')
+ await stage(
+ 'caption',
+ 'Ninelands',
+ 'A new 9×9 map every day.',
+ 'A daily strategy game on Reddit',
+ {
+ logo: true,
+ instant: true,
+ },
+ )
+ await page.waitForTimeout(500)
+ const t0 = Date.now()
+ await o.snap('01-daily-post')
+ await wait(2800)
+
+ await stage(
+ 'caption',
+ 'One map for the whole subreddit',
+ 'Every day a fresh post – everyone plays the same map.',
+ )
+ await wait(1800)
+ if (clips.feed && !o.fast) {
+ await stage('show', 'clip', clips.feed)
+ await wait(Math.min(8, await stage('clipDuration')) * 1000)
+ await stage('show', 'post')
+ }
+ const splash = frameOf('splash.html')
+ await tap(splash.locator('button.primary'), 500, false)
+ await wait(300)
+ await stage('finger', null)
+ await stage('show', 'game', '/game.html')
+ const g = frameOf('game.html')
+ await g.waitForSelector('.board .cell')
+
+ await stage(
+ 'caption',
+ 'Build your realm',
+ 'Place a castle, then farmers, miners and knights.',
+ )
+ await wait(900)
+ let tool = 'HIVEMOTHER'
+ for (let i = 0; i < p.moves.length; i++) {
+ const m = p.moves[i]
+ if (m.unit !== tool) {
+ await tap(g.locator('.tool').nth(UNIT_ORDER.indexOf(m.unit)), 200)
+ tool = m.unit
+ await wait(150)
+ }
+ if (i === 4)
+ await stage(
+ 'caption',
+ 'Every piece claims land',
+ 'Claimed land is gone – plan where you build.',
+ )
+ if (i === 13)
+ await stage(
+ 'caption',
+ 'Gold and lands',
+ 'Mine ore and herbs, rule a land by claiming half of it.',
+ )
+ const cell = g.locator(`.board .cell[data-i="${m.cell}"]`)
+ if (i === snapAt) {
+ await cell.hover()
+ await o.snap('02-claim-land')
+ }
+ await tap(cell, i < 4 ? 650 : 320)
+ if (i === 0) tool = 'HIVEWORKER' // nach der Burg wählt das Spiel Bauern
+ await wait(i < 4 ? 350 : 180)
+ }
+ await page.mouse.move(5, 5)
+ await stage('finger', null)
+ await stage(
+ 'caption',
+ 'Three goals, three stars',
+ 'Reach the gold goal, rule the lands – or both.',
+ )
+ await wait(1600)
+ await o.snap('03-three-stars')
+ if (!p.natural) {
+ await stage(
+ 'caption',
+ 'Know when to stop',
+ 'No good move left? End the game and keep your gold.',
+ )
+ await wait(900)
+ const end = g.locator('button.end')
+ await tap(end, 300)
+ await wait(500)
+ await tap(end, 200)
+ }
+ await g.waitForSelector('.overlay .result')
+ await stage('finger', null)
+ await stage(
+ 'caption',
+ 'Only your first try counts',
+ 'Climb the daily leaderboard and keep your streak alive.',
+ )
+ await wait(2600)
+ await o.snap('04-result')
+
+ const comment = g.locator('.overlay button', {hasText: 'Post to comments'})
+ await stage(
+ 'caption',
+ 'Talk about it',
+ 'Post your result to the comments with one tap.',
+ )
+ await wait(700)
+ await tap(comment, 400)
+ await wait(1400)
+ if (clips.comment && !o.fast) {
+ await stage('show', 'clip', clips.comment)
+ await wait(Math.min(8, await stage('clipDuration')) * 1000)
+ }
+ await stage('finger', null)
+
+ // Zurück im Post: neues Ergebnis, Realm mit frisch freigeschaltetem Winterbrett
+ await stage('show', 'post', '/splash.html')
+ await stage(
+ 'caption',
+ 'Unlock new realms',
+ 'Streaks and stars unlock boards, peoples and banners.',
+ )
+ await wait(900)
+ const s2 = frameOf('splash.html')
+ await tap(s2.locator('.realm-btn'), 300)
+ await wait(700)
+ await tap(s2.locator('.realm-item', {hasText: 'Winter'}).first(), 400)
+ await wait(900)
+ await stage('finger', null)
+ await o.snap('05-realm')
+ await wait(800)
+ await tap(s2.locator('.overlay button.primary', {hasText: 'Done'}), 300)
+ await stage('finger', null)
+ await wait(400)
+
+ await stage(
+ 'caption',
+ 'Ninelands',
+ 'Play today’s map on r/Ninelands',
+ 'A daily port of Ninelands · ninelands.stackteam.at',
+ {
+ logo: true,
+ },
+ )
+ await wait(3200)
+ await o.snap('06-outro')
+ return t0
+}
+
+// 5. Aufnahme
+const browser = await chromium.launch()
+try {
+ const videoDir = join(work, 'video')
+ rmSync(videoDir, {recursive: true, force: true})
+ const ctx = await browser.newContext({
+ viewport: {width: 1920, height: 1080},
+ recordVideo: {dir: videoDir, size: {width: 1920, height: 1080}},
+ })
+ const start = Date.now()
+ const page = await ctx.newPage()
+ page.on('pageerror', e => console.error(`Seitenfehler: ${e.message}`))
+ const t0 = await scenes(page, {fast: false, snap: async () => {}})
+ const end = Date.now()
+ await ctx.close()
+ const webm = join(videoDir, readdirSync(videoDir)[0])
+ const from = (t0 - start) / 1000
+ const length = (end - t0) / 1000
+ const music = join(root, 'public/audio/music/peaceful-village.mp3')
+ ffmpeg(
+ '-ss',
+ from.toFixed(2),
+ '-i',
+ webm,
+ '-i',
+ music,
+ '-t',
+ length.toFixed(2),
+ '-r',
+ '30',
+ '-c:v',
+ 'libx264',
+ '-preset',
+ 'slow',
+ '-crf',
+ '18',
+ '-pix_fmt',
+ 'yuv420p',
+ '-af',
+ `afade=t=in:d=1,afade=t=out:st=${(length - 2).toFixed(2)}:d=2`,
+ '-c:a',
+ 'aac',
+ '-b:a',
+ '160k',
+ '-map',
+ '0:v',
+ '-map',
+ '1:a',
+ '-shortest',
+ '-movflags',
+ '+faststart',
+ join(out, 'trailer.mp4'),
+ )
+ console.log(
+ `trailer.mp4 ${length.toFixed(1)} s ${Math.round(statSync(join(out, 'trailer.mp4')).size / 1024)} KB`,
+ )
+
+ // Standbilder ohne Pausen: Galerie 3:2 (1800 × 1200) mit Titelbild, Vimeo-Cover 16:9 (1920 × 1080)
+ const COVER = [
+ 'Ninelands',
+ 'A new 9×9 map every day.',
+ 'A daily strategy game on Reddit',
+ ]
+ async function stills(viewport, onSnap) {
+ const still = await browser.newPage({viewport})
+ still.on('pageerror', e => console.error(`Seitenfehler: ${e.message}`))
+ await scenes(still, {
+ fast: true,
+ snap: async name => {
+ await still.evaluate(() => window.stage.finger(null))
+ await still.waitForTimeout(450) // Animationen auslaufen lassen
+ await onSnap(still, name)
+ },
+ })
+ await still.close()
+ }
+ /** Titelbild: Logo links, Brett mit drei Sternen rechts */
+ async function cover(page, path) {
+ const old = await page.evaluate(() => [
+ document.getElementById('title').textContent,
+ document.getElementById('sub').textContent,
+ ])
+ await page.evaluate(
+ c => window.stage.caption(c[0], c[1], c[2], {logo: true, instant: true}),
+ COVER,
+ )
+ await page.screenshot({path})
+ await page.evaluate(
+ o => window.stage.caption(o[0], o[1], '', {instant: true}),
+ old,
+ )
+ }
+ await stills({width: 1800, height: 1200}, async (page, name) => {
+ if (name === '03-three-stars')
+ await cover(page, join(gallery, '00-cover.png'))
+ await page.screenshot({path: join(gallery, `${name}.png`)})
+ })
+ await stills({width: 1920, height: 1080}, async (page, name) => {
+ if (name === '03-three-stars')
+ await cover(page, join(out, 'vimeo-cover.png'))
+ })
+ for (const f of readdirSync(gallery))
+ console.log(
+ `gallery/${f} ${Math.round(statSync(join(gallery, f)).size / 1024)} KB`,
+ )
+ if (!existsSync(join(out, 'vimeo-cover.png'))) console.error('Cover fehlt')
+} finally {
+ await browser.close()
+ server.close()
+}