feat: Bilder und Texte für die Community-Posts in r/Ninelands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 21:41:46 +01:00
parent 1cf5de9ae0
commit 099f884dcb
7 changed files with 637 additions and 1 deletions

View file

@ -298,3 +298,12 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game
- Credits-Fenster im Post geprüft: passt auch bei 320 px Breite (Knopf endet bei 413 von 512 px).
- Pixelify-Ligatur „fi“ (sieht aus wie „A“) jetzt für die ganze App aus (`font-variant-ligatures: none`).
- **Fallstrick:** `devvit.json` geändert → laufenden Playtest stoppen und neu starten.
### 2026-11-13 – Bilder für die Community-Posts
- `npm run capture:community` (`scripts/capture/community.{mjs,ts,html,css}`): zwei Bilder 1080 × 1350 (4:5, im Feed
am Handy groß) – „How to play“ (vier Figuren mit beanspruchten Feldern, Gold je Gelände, Sterne) und „Choose your
realm“ (Völker, Bretter, Banner mit Freischalt-Bedingung). Muster kommen aus `UNIT_DEFS` (Strahlen/Ring, Budget),
Bedingungen aus `look.ts` – stimmen also nach Regeländerungen. Seite 540 × 675 mit Faktor 2 aufgenommen, Pixel scharf.
- Das Skript meldet, wenn der Inhalt höher als das Bild ist („… px zu hoch“).
- Texte der drei Posts: `docs/community-posts.md`.

69
docs/community-posts.md Normal file
View file

@ -0,0 +1,69 @@
# Community-Posts in r/Ninelands (Entwürfe)
Drei eigene Posts zum Go-live, kurz nacheinander: How to play und Go-live anheften. Am selben Tag die App
installieren – das legt sofort den Tagespost #1 an. Bilder erzeugen: `npm run capture:community` →
`dist/marketing/community/` (1080 × 1350, Werte aus Engine und `src/shared/look.ts`). Video für Post 3:
`npm run capture:trailer` → `dist/marketing/trailer.mp4`.
## 1 – How to play (Bildpost: `how-to-play.png`)
Titel: `How to play Ninelands`
```text
Every day at 00:00 UTC a new 9×9 map appears as a post here. Everyone plays the same map.
**Goal:** earn gold and rule the nine lands.
- **Castle** (free, once): claims up to 5 cells in straight lines.
- **Farmer** (free): claims the 8 cells around it, inside its own land. Must border your domain.
- **Knight** (10 gold): 2 cells straight, stays in its land – pays double.
- **Bishop** (15 gold): 2 cells diagonally, also across borders.
Claimed cells pay gold: ore and herbs 20, pasture 5 (7 next to a lake). Nothing can be built on claimed land, so
plan where you build. You rule a land when you claim at least half of it.
**Stars:** ★ reach the gold goal, ★ rule the lands goal, ★ both in one game.
**Law of the day:** each map has one special rule, e.g. "Costly knights".
**End game:** when no move earns gold anymore, end the game to keep your gold.
Only your **first game** counts for the leaderboard – after that you can practice. Play daily for a 🔥 streak;
yesterday's winner wears the 👑 flair.
```
## 2 – Peoples & realms (Bildpost: `realms.png`)
Titel: `Choose your people: Kingdom, Orcs and Pirates`
```text
Your realm, your look. Tap 🎨 Realm on the daily post:
- **Colours** – red, cyan, green, purple – free for everyone
- **Peoples** – Kingdom, Orcs (20 ★), Pirates (40 ★)
- **Boards** – Autumn (3-day streak), Winter (7), Night (14)
- **Banners** – a new one every 10 stars
- **Winterfest** (Dec 1 – Jan 6): snowy board and snowflake banner for everyone
Same rules for all – it's just about looking good while you win. Which people are you playing?
```
## 3 – Go-live (Videopost: `trailer.mp4`, sonst `vimeo-cover.png`)
Titel: `Ninelands is live – a new 9×9 map every day`
```text
Ninelands is a small strategy game I've been building – and now there's a daily version right here on Reddit.
Every day: one new map, one leaderboard, one first try. Play it in the daily post, compare your realm in the
comments, keep your streak going.
Today's map is #1 – good luck, rulers!
New here? Read "How to play" (pinned). Bugs, ideas or feedback: comment below.
Original game: ninelands.stackteam.at
Music: "Dreamscape (part I)" by Vee Ray (CC BY-SA 4.0) · Sprites: "Mini World Sprites" by Shade & octoshrimpy (CC0)
```
Hinweise: Kosten in Post 1 sind die normalen (einzelne Gesetze ändern sie). „I've been building“ ist persönlich
gemeint – als StackTeam „we“ schreiben. Ändern sich Kosten oder Freischaltungen, Bilder neu erzeugen und Texte
anpassen.

View file

@ -18,7 +18,8 @@
"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:trailer": "node scripts/capture/trailer.mjs"
"capture:trailer": "node scripts/capture/trailer.mjs",
"capture:community": "node scripts/capture/community.mjs"
},
"devDependencies": {
"@biomejs/biome": "2.6.6",

View file

@ -0,0 +1,234 @@
/* Bilder für die Community-Posts in r/Ninelands: 540 × 675 CSS-Pixel, aufgenommen mit Faktor 2 (1080 × 1350). */
html,
body {
margin: 0;
width: 540px;
height: 675px;
overflow: hidden;
}
#sheet {
box-sizing: border-box;
width: 540px;
height: 675px;
padding: 14px 22px 12px;
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--font-pixel);
color: var(--px-parchment);
font-variant-ligatures: none;
}
img {
image-rendering: pixelated;
}
.title {
text-align: center;
}
.title h1 {
margin: 0;
font-size: 31px;
color: var(--px-gold);
text-shadow: 3px 3px 0 var(--px-ink);
}
.title p {
margin: 2px 0 0;
font-size: 14px;
color: var(--px-parchment-dim);
}
.card {
background: var(--px-wood);
border: 3px solid var(--px-ink);
box-shadow: var(--px-shadow);
padding: 6px 10px;
}
.card h2 {
margin: 0;
font-size: 17px;
color: var(--px-gold);
display: flex;
justify-content: space-between;
align-items: baseline;
}
.card small {
font-size: 13px;
color: var(--px-parchment);
}
.card p {
margin: 2px 0 6px;
font-family: var(--font-text);
font-size: 11.5px;
line-height: 1.3;
color: var(--px-parchment-dim);
}
/* Regeltafel: 2 × 2 Figuren */
.units {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.pattern {
display: grid;
width: fit-content;
margin: 0 auto;
border: 2px solid var(--px-ink);
}
.pattern .c {
position: relative;
background-size: cover;
image-rendering: pixelated;
}
.pattern .c.claim::after {
content: '';
position: absolute;
inset: 0;
background: rgba(150, 70, 255, 0.55);
outline: 1px dashed rgba(255, 255, 255, 0.7);
outline-offset: -2px;
}
.c > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.row {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-around;
}
.gold-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
}
.gold-item img {
width: 30px;
height: 30px;
}
.gold-item b {
color: var(--px-gold);
}
.stars {
color: var(--px-gold);
}
.foot {
margin: auto 0 0;
text-align: center;
font-size: 13px;
color: var(--px-parchment-dim);
}
/* Realm-Übersicht */
.peoples {
display: grid;
grid-template-columns: auto repeat(4, 1fr);
align-items: center;
gap: 2px 6px;
}
.peoples img {
width: 40px;
height: 40px;
justify-self: center;
}
.peoples .name {
font-size: 15px;
}
.lock {
display: block;
font-size: 11px;
color: var(--px-gold);
}
.tiles {
display: flex;
justify-content: space-between;
gap: 6px;
}
.tile {
flex: 1;
text-align: center;
font-size: 13px;
}
.patch {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 66px;
height: 66px;
margin: 0 auto 3px;
position: relative;
border: 2px solid var(--px-ink);
}
.patch span {
background-size: cover;
image-rendering: pixelated;
}
.patch img {
position: absolute;
left: 22px;
top: 22px;
width: 22px;
height: 22px;
}
.banners {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
text-align: center;
font-size: 12px;
}
.banners svg {
display: block;
width: 38px;
height: 38px;
margin: 0 auto;
}
.stars-card {
text-align: center;
font-size: 16px;
white-space: pre;
}
.c > img.big {
inset: -100%;
width: 300%;
height: 300%;
z-index: 2;
}
.gold-item small {
font-size: 12px;
color: var(--px-parchment-dim);
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ninelands community images</title>
<link rel="stylesheet" href="community.css">
</head>
<body>
<main id="sheet"></main>
<script src="community.js" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,91 @@
#!/usr/bin/env node
// Bilder für die Community-Posts in r/Ninelands (1080 × 1350, hochkant für den Feed am Handy).
// Aufruf: npm run capture:community → dist/marketing/community/how-to-play.png, realms.png
import {copyFileSync, mkdirSync, 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/community')
const out = join(root, 'dist/marketing/community')
mkdirSync(site, {recursive: true})
mkdirSync(out, {recursive: true})
await build({
entryPoints: [join(root, 'scripts/capture/community.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/community.html'),
join(site, 'index.html'),
)
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)
try {
if (file.startsWith(dir) && statSync(file).isFile()) {
res.writeHead(200, {
'Content-Type': types[extname(file)] ?? 'application/octet-stream',
})
res.end(readFileSync(file))
return
}
} catch {}
}
res.writeHead(404)
res.end()
})
await new Promise(r => server.listen(0, '127.0.0.1', r))
const base = `http://127.0.0.1:${server.address().port}/`
const browser = await chromium.launch()
try {
for (const page of ['howto', 'realms']) {
const p = await browser.newPage({
viewport: {width: 540, height: 675},
deviceScaleFactor: 2,
})
p.on('pageerror', e => console.error(`Seitenfehler: ${e.message}`))
await p.goto(`${base}?page=${page}`)
await p.evaluate(() => document.fonts.ready)
await p.waitForLoadState('networkidle')
// Passt alles aufs Bild?
const over = await p.evaluate(() => {
const s = document.getElementById('sheet')
return s ? s.scrollHeight - s.clientHeight : 0
})
if (over > 0) console.error(`${page}: ${over} px zu hoch`)
const name = page === 'howto' ? 'how-to-play.png' : 'realms.png'
await p.screenshot({path: join(out, name)})
console.log(
`${name} ${Math.round(statSync(join(out, name)).size / 1024)} KB`,
)
await p.close()
}
} finally {
await browser.close()
server.close()
}

View file

@ -0,0 +1,220 @@
// Bilder für die Community-Posts (How to play, Realms). Werte kommen aus Engine und look.ts, damit die Bilder
// stimmen, wenn sich Regeln oder Freischaltungen ändern. ?page=howto | realms
import '../../src/client/style.css'
import './community.css'
import {h} from '../../src/client/dom.ts'
import {UNIT_NAME} from '../../src/client/texts.ts'
import {spriteUrl} from '../../src/engine/app/board/atlas.ts'
import {
bannerPreview,
pieceSpriteName,
} from '../../src/engine/app/board/BoardView.ts'
import {TEAM_HEX} from '../../src/engine/core/avatars.ts'
import {
UNIT_DEFS,
UNIT_TYPES,
type UnitType,
} from '../../src/engine/core/units.ts'
import {
BANNER_OPTIONS,
COLORS,
FACTION_OPTIONS,
THEME_OPTIONS,
type Unlock,
unlockText,
} from '../../src/shared/look.ts'
const sheet = document.getElementById('sheet') as HTMLElement
const COLOR = 'purple'
/** Normale Kosten (ohne Gesetz des Tages) */
const COST: Record<UnitType, string> = {
HIVEMOTHER: 'free',
HIVEWORKER: 'free',
DRONE: '10 gold',
HIVESCOUT: '15 gold',
}
const RULE: Record<UnitType, string> = {
HIVEMOTHER: 'Only once. Up to 5 cells per line.',
HIVEWORKER: 'The 8 cells around it, own land.',
DRONE: '2 cells per line, own land. Pays 2×.',
HIVESCOUT: '2 cells per diagonal, any land.',
}
/** Beanspruchte Felder auf freiem Grasland – direkt aus den Engine-Daten (Strahlen bzw. Ring) */
function claimed(unit: UnitType, n: number): Set<number> {
const def = UNIT_DEFS[unit]
const mid = (n - 1) / 2
const out = new Set<number>()
for (const [dx, dy] of def.directions) {
const steps = def.shape === 'ring' ? 1 : def.budget
for (let s = 1; s <= steps; s++) {
const x = mid + dx * s
const y = mid + dy * s
if (x >= 0 && y >= 0 && x < n && y < n) out.add(y * n + x)
}
}
return out
}
function pattern(unit: UnitType): HTMLElement {
const n = unit === 'HIVEMOTHER' ? 11 : 5
const size = unit === 'HIVEMOTHER' ? 12 : 26
const grid = h('div.pattern')
grid.style.gridTemplateColumns = `repeat(${n}, ${size}px)`
grid.style.gridAutoRows = `${size}px`
const claim = claimed(unit, n)
for (let i = 0; i < n * n; i++) {
const c = h('span.c')
c.style.backgroundImage = `url(${spriteUrl(`grass${(i * 7) % 6}` as 'grass0')})`
if (claim.has(i)) c.classList.add('claim')
if (i === (n * n - 1) / 2) {
const piece = img(pieceSpriteName(unit, COLOR, 'kingdom'))
// Burg auf dem großen Raster dreifach groß, sonst kaum zu erkennen
if (n > 5) piece.classList.add('big')
c.append(piece)
}
grid.append(c)
}
return grid
}
function img(name: string): HTMLImageElement {
const el = h('img')
el.src = name.startsWith('/') ? name : spriteUrl(name as 'grass0')
el.alt = ''
return el
}
function howto(): void {
// Reihenfolge wie man spielt: Burg, Bauer, Ritter, Läufer
const order: UnitType[] = ['HIVEMOTHER', 'HIVEWORKER', 'DRONE', 'HIVESCOUT']
if (order.some(u => !UNIT_TYPES.includes(u))) throw new Error('Einheiten')
sheet.append(
h(
'header.title',
h('h1', 'How to play'),
h('p', 'Ninelands · a new 9×9 map every day'),
),
h(
'section.units',
...order.map(u =>
h(
'div.card',
h('h2', UNIT_NAME[u], h('small', COST[u])),
h('p', RULE[u]),
pattern(u),
),
),
),
h(
'section.card',
h('h2', 'Claimed cells pay gold'),
h(
'div.row',
h('span.gold-item', img('ore'), 'Ore ', h('b', '20')),
h('span.gold-item', img('herbs'), 'Herbs ', h('b', '20')),
h(
'span.gold-item',
img('grass0'),
'Pasture ',
h('b', '5'),
h('small', ' (7 by a lake)'),
),
),
h(
'p',
'Claimed land can’t be built on – plan ahead. Claim half of a land to rule it.',
),
),
h(
'section.card.stars-card',
h('span.stars', '★'),
' gold goal ',
h('span.stars', '★'),
' lands goal ',
h('span.stars', '★'),
' both in one game',
),
h(
'p.foot',
'Only your first game counts · each map has its own law · new map 00:00 UTC',
),
)
}
const NAMES: Record<string, string> = {
kingdom: 'Kingdom',
orc: 'Orcs',
pirate: 'Pirates',
classic: 'Classic',
autumn: 'Autumn',
winter: 'Winter',
night: 'Night',
winterfest: 'Winterfest',
}
const lock = (u: Unlock) =>
u.kind === 'free' ? h('span.lock', 'free') : h('span.lock', unlockText(u))
function realms(): void {
const peoples = h('div.peoples')
FACTION_OPTIONS.forEach((f, row) => {
peoples.append(h('span.name', NAMES[f.id] ?? f.id, lock(f.unlock)))
for (const u of UNIT_TYPES) {
// Jede Zeile in einer anderen Farbe – zeigt Völker und Farben zugleich
const color = COLORS[(row + 1) % COLORS.length]?.id ?? 'red'
peoples.append(img(pieceSpriteName(u, color, f.id)))
}
})
const tiles = h(
'div.tiles',
...THEME_OPTIONS.map(t => {
const patch = h('div.patch')
for (let i = 0; i < 9; i++) {
const s = h('span')
const file = `grass${(i * 5) % 6}.png`
s.style.backgroundImage = `url(/sprites/themes/${t.id}/${file})`
patch.append(s)
}
patch.append(img(pieceSpriteName('HIVEMOTHER', COLOR, 'kingdom')))
return h('div.tile', patch, NAMES[t.id] ?? t.id, lock(t.unlock))
}),
)
const banners = h(
'div.banners',
...BANNER_OPTIONS.map(b =>
h(
'div',
bannerPreview(b.id, TEAM_HEX[COLOR]),
`${b.id[0]?.toUpperCase()}${b.id.slice(1)}`,
lock(b.unlock),
),
),
)
sheet.append(
h(
'header.title',
h('h1', 'Choose your realm'),
h(
'p',
'Tap 🎨 Realm on the daily post · 4 colours free · same rules for all',
),
),
h('section.card', h('h2', 'Peoples', h('small', 'collect stars')), peoples),
h('section.card', h('h2', 'Boards', h('small', 'keep your streak')), tiles),
h(
'section.card',
h('h2', 'Banners', h('small', 'a new one every 10 ★')),
banners,
),
h(
'p.foot',
'It’s all about looking good while you win – which people are you playing?',
),
)
}
const page = new URLSearchParams(location.search).get('page')
if (page === 'realms') realms()
else howto()