From d3ab781395f2ba33a8fbf4c492410d0ddc90367e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Fri, 13 Nov 2026 19:57:04 +0100 Subject: [PATCH] feat: Trailer, Devpost-Galerie und Vimeo-Cover per npm run capture:trailer Co-Authored-By: Claude Opus 5.5 --- PLAN.md | 16 ++ docs/submission.md | 30 +++ package.json | 3 +- scripts/capture/plan.ts | 58 +++++ scripts/capture/stage.css | 173 +++++++++++++ scripts/capture/stage.html | 27 ++ scripts/capture/stage.ts | 90 +++++++ scripts/capture/trailer.mjs | 487 ++++++++++++++++++++++++++++++++++++ 8 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 scripts/capture/plan.ts create mode 100644 scripts/capture/stage.css create mode 100644 scripts/capture/stage.html create mode 100644 scripts/capture/stage.ts create mode 100644 scripts/capture/trailer.mjs 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 + + + +
+

Ninelands

+

+ +
+
+
+
+

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