Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dd09c58fec
6 changed files with 432 additions and 1 deletions
11
PLAN.md
11
PLAN.md
|
|
@ -202,3 +202,14 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game
|
|||
nicht: Hinweis „None of your remaining moves earns gold – end the game to keep your gold.“ und „End game“ leuchtet golden.
|
||||
- Fix: Im goldenen Ziel-Feld waren Stern und „/ Ziel“ ebenfalls gold (Gold auf Gold, unsichtbar) – beim Umsortieren
|
||||
der CSS-Regeln für Biome (`noDescendingSpecificity`) waren die Deklarationen vertauscht. Jetzt alles in Tinte.
|
||||
|
||||
### 2026-11-13 – Katalog-Assets (Entwurf)
|
||||
|
||||
- `npm run capture` (`scripts/capture/`): baut eine Aufnahmeseite nur mit dem Brett, ein Bot (`smart`) spielt eine
|
||||
Partie mit Vorschau, Playwright nimmt auf, ffmpeg macht daraus `dist/marketing/featuring.mp4` (1500×1000, 30 fps,
|
||||
≈ 11 s, ≈ 1,7 MB), `fallback.gif` (1000×667, ≤ 2 MB), `fallback.png` (1500×1000) und `tile.png` (560×256 mit
|
||||
Schriftzug). Maße laut Developer Portal; Größen werden bei Bedarf automatisch heruntergeregelt.
|
||||
- **Karte vom 2026-10-10** (vor dem ersten Spieltag): läuft nie als Tageskarte – eine künftige Tageskarte im Video
|
||||
wäre ein Spoiler. Der Bot holt dort in 26 Zügen alle 3 Sterne.
|
||||
- Playwright als devDependency in der Version des Hauptspiels (1.55.1), damit die vorhandenen Browser im Cache passen.
|
||||
- Vor der Einreichung neu aufnehmen (z. B. mit Winter-Thema aus Phase 2).
|
||||
|
|
|
|||
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
|||
"@types/node": "24.19.2",
|
||||
"devvit": "0.15.3",
|
||||
"esbuild": "0.28.2",
|
||||
"playwright": "1.55.1",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -5427,6 +5428,53 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
||||
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"test:types": "tsc --build",
|
||||
"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' --"
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.6.6",
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
"@types/node": "24.19.2",
|
||||
"devvit": "0.15.3",
|
||||
"esbuild": "0.28.2",
|
||||
"playwright": "1.55.1",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
66
scripts/capture/capture.html
Normal file
66
scripts/capture/capture.html
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ninelands capture</title>
|
||||
<link rel="stylesheet" href="capture.css">
|
||||
<style>
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
#board {
|
||||
height: 94vh;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
/* Kachel: Ausschnitt des Bretts mit Schriftzug */
|
||||
body.tile #board {
|
||||
height: auto;
|
||||
width: 100vw;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
#logo {
|
||||
display: none;
|
||||
}
|
||||
body.tile #logo {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: var(--font-pixel);
|
||||
font-weight: 700;
|
||||
font-size: 72px;
|
||||
color: var(--px-gold);
|
||||
background: var(--px-ink);
|
||||
padding: 6px 28px 10px;
|
||||
border: 6px solid var(--px-gold-dark);
|
||||
text-shadow: 4px 4px 0 #000;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
|
||||
<symbol id="i-warn" viewBox="0 0 24 24">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3 2 20h20zM12 10v4M12 17v.5" />
|
||||
</g>
|
||||
</symbol>
|
||||
</svg>
|
||||
<div id="board"></div>
|
||||
<div id="logo">Ninelands</div>
|
||||
<script src="capture.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
114
scripts/capture/capture.ts
Normal file
114
scripts/capture/capture.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Aufnahmeseite für die Katalog-Assets: nur das Brett, ein Bot spielt eine echte Partie.
|
||||
// Gesteuert von scripts/capture/record.mjs über window.capture.
|
||||
import '../../src/client/style.css'
|
||||
import {ZONE_NAMES} from '../../src/client/texts.ts'
|
||||
import {BoardView} from '../../src/engine/app/board/BoardView.ts'
|
||||
import {BOTS} from '../../src/engine/core/bots.ts'
|
||||
import {Game} from '../../src/engine/core/Game.ts'
|
||||
import {mulberry32} from '../../src/engine/core/rng.ts'
|
||||
import {dailyBoard, dailyMap} from '../../src/shared/daily.ts'
|
||||
|
||||
const params = new URLSearchParams(location.search)
|
||||
// Karte von vor dem ersten Spieltag – läuft nie als Tageskarte, verrät also nichts
|
||||
const day = params.get('day') ?? '2026-10-10'
|
||||
const seed = Number(params.get('seed') ?? 1)
|
||||
|
||||
const view = new BoardView({interactive: true})
|
||||
const game = new Game(dailyBoard(dailyMap(new Date(`${day}T00:00:00Z`))), {
|
||||
undoLimit: 0,
|
||||
})
|
||||
view.setBoard(game.board)
|
||||
view.setLandNames(ZONE_NAMES)
|
||||
const host = document.getElementById('board') as HTMLDivElement
|
||||
host.append(view.svg)
|
||||
|
||||
// Ganze Partie vorab berechnen, damit die Aufnahme nur noch abspielt
|
||||
const moves: {unit: Parameters<Game['place']>[0]; cell: number}[] = []
|
||||
{
|
||||
const sim = game.clone()
|
||||
const rng = mulberry32(seed)
|
||||
while (!sim.isGameOver()) {
|
||||
const m = BOTS.smart(sim, rng)
|
||||
sim.place(m.unit, m.cell)
|
||||
moves.push({unit: m.unit, cell: m.cell})
|
||||
}
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
view.update(game)
|
||||
view.setRulers(game.rulers())
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
/** Einen Zug zeigen: kurz die Vorschau (beanspruchtes Land + Gold), dann setzen */
|
||||
async function step(
|
||||
i: number,
|
||||
previewMs: number,
|
||||
afterMs: number,
|
||||
): Promise<void> {
|
||||
const m = moves[i]
|
||||
if (!m) return
|
||||
const p = game.preview(m.unit, m.cell)
|
||||
if (p.ok) {
|
||||
view.setPlacementPreview({
|
||||
cell: m.cell,
|
||||
unit: m.unit,
|
||||
zoi: p.zoi,
|
||||
label: `${p.net >= 0 ? '+' : '−'}${Math.abs(p.net)}`,
|
||||
owner: 1,
|
||||
tone: p.net > 0 ? 'gain' : p.net < 0 ? 'loss' : 'neutral',
|
||||
})
|
||||
await sleep(previewMs)
|
||||
}
|
||||
view.setPlacementPreview(null)
|
||||
const r = game.place(m.unit, m.cell)
|
||||
if (r.ok) {
|
||||
const net = r.yield.points - r.cost
|
||||
if (net !== 0)
|
||||
view.floatLabel(
|
||||
m.cell,
|
||||
`${net > 0 ? '+' : '−'}${Math.abs(net)}`,
|
||||
net > 0 ? 'gain' : 'loss',
|
||||
)
|
||||
}
|
||||
draw()
|
||||
await sleep(afterMs)
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
capture: {
|
||||
moves: number
|
||||
/** Partie abspielen: firstMoves sofort (für ein aussagekräftiges erstes Bild), dann im Takt */
|
||||
play(
|
||||
firstMoves: number,
|
||||
previewMs: number,
|
||||
afterMs: number,
|
||||
): Promise<void>
|
||||
/** Sofort bis Zug n springen (Standbilder) */
|
||||
jump(n: number): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.capture = {
|
||||
moves: moves.length,
|
||||
async play(firstMoves, previewMs, afterMs) {
|
||||
for (let i = 0; i < firstMoves; i++) {
|
||||
const m = moves[i]
|
||||
if (m) game.place(m.unit, m.cell)
|
||||
}
|
||||
draw()
|
||||
for (let i = firstMoves; i < moves.length; i++)
|
||||
await step(i, previewMs, afterMs)
|
||||
},
|
||||
jump(n) {
|
||||
for (let i = game.moves.length; i < Math.min(n, moves.length); i++) {
|
||||
const m = moves[i]
|
||||
if (m) game.place(m.unit, m.cell)
|
||||
}
|
||||
draw()
|
||||
},
|
||||
}
|
||||
draw()
|
||||
190
scripts/capture/record.mjs
Normal file
190
scripts/capture/record.mjs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env node
|
||||
// Katalog-Assets für das Developer Portal aufnehmen (Maße laut Portal, Stand 2026-11-13):
|
||||
// featuring.mp4 3:2, 1500×1000, 6–15 s, ≤ 30 fps, ≤ 3 MB
|
||||
// fallback.gif 3:2, 1000×667, 3–10 s Schleife, Ziel ≤ 2 MB
|
||||
// fallback.png 1500×1000, ≤ 800 KB (Standbild statt GIF)
|
||||
// tile.png 560×256, ≤ 800 KB
|
||||
// Nur das Brett, keine Menüs. Braucht ffmpeg. Aufruf: npm run capture (Ergebnis in dist/marketing/)
|
||||
import {execFileSync} from 'node:child_process'
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
} from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {dirname, extname, join, resolve} from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
import {build} from 'esbuild'
|
||||
import {chromium} from 'playwright'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const site = join(root, 'dist/capture')
|
||||
const out = join(root, 'dist/marketing')
|
||||
mkdirSync(site, {recursive: true})
|
||||
mkdirSync(out, {recursive: true})
|
||||
|
||||
// 1. Aufnahmeseite bauen (gleiche Defines wie build:client)
|
||||
await build({
|
||||
entryPoints: [join(root, 'scripts/capture/capture.ts')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
outdir: site,
|
||||
logLevel: 'warning',
|
||||
define: {
|
||||
'import.meta.env.MODE': '"reddit"',
|
||||
'import.meta.env.BASE_URL': '"/"',
|
||||
__API_ORIGIN__: '""',
|
||||
},
|
||||
loader: {'.woff2': 'file', '.woff': 'file'},
|
||||
assetNames: 'fonts/[name]-[hash]',
|
||||
})
|
||||
copyFileSync(
|
||||
join(root, 'scripts/capture/capture.html'),
|
||||
join(site, 'index.html'),
|
||||
)
|
||||
|
||||
// 2. Statischer Server: zuerst die Aufnahmeseite, dann public/ (Sprites)
|
||||
const types = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.png': 'image/png',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
}
|
||||
const server = createServer((req, res) => {
|
||||
const path = decodeURIComponent((req.url ?? '/').split('?')[0])
|
||||
for (const dir of [site, join(root, 'public')]) {
|
||||
const file = join(dir, path === '/' ? 'index.html' : path)
|
||||
if (file.startsWith(dir) && existsFile(file)) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': types[extname(file)] ?? 'application/octet-stream',
|
||||
})
|
||||
res.end(readFileSync(file))
|
||||
return
|
||||
}
|
||||
}
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
})
|
||||
function existsFile(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}/`
|
||||
const ffmpeg = (...args) =>
|
||||
execFileSync('ffmpeg', ['-v', 'error', '-y', ...args])
|
||||
const size = f => statSync(join(out, f)).size
|
||||
const kb = f => `${Math.round(size(f) / 1024)} KB`
|
||||
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
// 3. Video: Partie mit Vorschau im Takt; die ersten 2 Züge (Burg + Bauer) stehen schon im ersten Bild
|
||||
const videoDir = join(site, 'video')
|
||||
const ctx = await browser.newContext({
|
||||
viewport: {width: 1500, height: 1000},
|
||||
recordVideo: {dir: videoDir, size: {width: 1500, height: 1000}},
|
||||
})
|
||||
const t0 = Date.now()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(base)
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.waitForTimeout(400)
|
||||
const start = (Date.now() - t0) / 1000
|
||||
await page.evaluate(() => window.capture.play(2, 180, 200))
|
||||
await page.waitForTimeout(2200) // Endbild mit allen Bannern stehen lassen
|
||||
const length = (Date.now() - t0) / 1000 - start
|
||||
await ctx.close()
|
||||
const webm = join(videoDir, readdirSync(videoDir)[0])
|
||||
|
||||
// MP4 (H.264, 30 fps); CRF erhöhen, bis es unter 3 MB passt
|
||||
for (const crf of [20, 24, 28, 32]) {
|
||||
ffmpeg(
|
||||
'-ss',
|
||||
String(start),
|
||||
'-i',
|
||||
webm,
|
||||
'-t',
|
||||
String(Math.min(length, 15)),
|
||||
'-r',
|
||||
'30',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'slow',
|
||||
'-crf',
|
||||
String(crf),
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
'-an',
|
||||
join(out, 'featuring.mp4'),
|
||||
)
|
||||
if (size('featuring.mp4') <= 3 * 1024 * 1024) break
|
||||
}
|
||||
|
||||
// GIF (aus der Mitte, 1000×667, eigene Palette); Stufen probieren, bis ≤ 2 MB
|
||||
for (const [fps, colors, secs] of [
|
||||
[12, 96, 8],
|
||||
[10, 64, 8],
|
||||
[10, 64, 6],
|
||||
[8, 48, 6],
|
||||
]) {
|
||||
ffmpeg(
|
||||
'-ss',
|
||||
String(start + 1),
|
||||
'-t',
|
||||
String(secs),
|
||||
'-i',
|
||||
webm,
|
||||
'-vf',
|
||||
`fps=${fps},scale=1000:667:flags=lanczos,split[a][b];[a]palettegen=max_colors=${colors}:stats_mode=diff[p];[b][p]paletteuse=dither=none`,
|
||||
'-loop',
|
||||
'0',
|
||||
join(out, 'fallback.gif'),
|
||||
)
|
||||
if (size('fallback.gif') <= 2 * 1024 * 1024) break
|
||||
}
|
||||
|
||||
// 4. Standbilder: fertiges Brett (Fallback) und Kachel mit Schriftzug
|
||||
const still = await browser.newPage({viewport: {width: 1500, height: 1000}})
|
||||
await still.goto(base)
|
||||
await still.evaluate(() => document.fonts.ready)
|
||||
await still.evaluate(() => window.capture.jump(999))
|
||||
await still.waitForTimeout(300)
|
||||
await still.screenshot({path: join(out, 'fallback.png')})
|
||||
await still.close()
|
||||
|
||||
const tile = await browser.newPage({viewport: {width: 1120, height: 512}})
|
||||
await tile.goto(base)
|
||||
await tile.evaluate(() => {
|
||||
document.body.classList.add('tile')
|
||||
return document.fonts.ready
|
||||
})
|
||||
await tile.evaluate(() => window.capture.jump(999))
|
||||
await tile.waitForTimeout(300)
|
||||
await tile.screenshot({path: join(site, 'tile@2x.png')})
|
||||
await tile.close()
|
||||
ffmpeg(
|
||||
'-i',
|
||||
join(site, 'tile@2x.png'),
|
||||
'-vf',
|
||||
'scale=560:256:flags=lanczos',
|
||||
join(out, 'tile.png'),
|
||||
)
|
||||
|
||||
console.log(`Fertig in ${out}:`)
|
||||
for (const f of ['featuring.mp4', 'fallback.gif', 'fallback.png', 'tile.png'])
|
||||
console.log(` ${f} ${kb(f)}`)
|
||||
console.log(` Videolänge ≈ ${Math.min(length, 15).toFixed(1)} s`)
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
}
|
||||
Loading…
Reference in a new issue