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:
parent
8ee43eeb3f
commit
d3ab781395
8 changed files with 883 additions and 1 deletions
16
PLAN.md
16
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.*`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
58
scripts/capture/plan.ts
Normal 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
173
scripts/capture/stage.css
Normal 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);
|
||||
}
|
||||
}
|
||||
27
scripts/capture/stage.html
Normal file
27
scripts/capture/stage.html
Normal 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
90
scripts/capture/stage.ts
Normal 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
487
scripts/capture/trailer.mjs
Normal 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()
|
||||
}
|
||||
Loading…
Reference in a new issue