feat: Trailer, Devpost-Galerie und Vimeo-Cover per npm run capture:trailer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 19:57:04 +01:00
parent 8ee43eeb3f
commit d3ab781395
8 changed files with 883 additions and 1 deletions

16
PLAN.md
View file

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

View file

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

View file

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

58
scripts/capture/plan.ts Normal file
View file

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

173
scripts/capture/stage.css Normal file
View file

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

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ninelands trailer</title>
<link rel="stylesheet" href="stage.css">
</head>
<body>
<section id="text">
<h1 id="title">Ninelands</h1>
<p id="sub"></p>
<p id="foot"></p>
</section>
<div id="phone">
<div id="screen">
<div id="post">
<p class="post-head">r/Ninelands · daily post</p>
<iframe id="splash" title="Post"></iframe>
</div>
<iframe id="game" title="Game"></iframe>
<video id="clip" muted playsinline></video>
</div>
</div>
<div id="finger"></div>
<script src="stage.js" type="module"></script>
</body>
</html>

90
scripts/capture/stage.ts Normal file
View file

@ -0,0 +1,90 @@
// Bühne für Trailer und Galerie (scripts/capture/trailer.mjs steuert sie über window.stage).
import './stage.css'
const $ = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T
const text = $('text')
const title = $('title')
const sub = $('sub')
const foot = $('foot')
const finger = $('finger')
const splash = $<HTMLIFrameElement>('splash')
const game = $<HTMLIFrameElement>('game')
const clip = $<HTMLVideoElement>('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<void> {
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

487
scripts/capture/trailer.mjs Normal file
View file

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