diff --git a/PLAN.md b/PLAN.md index 5ae207e..b4d4af6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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`. diff --git a/docs/community-posts.md b/docs/community-posts.md new file mode 100644 index 0000000..8bd109f --- /dev/null +++ b/docs/community-posts.md @@ -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. diff --git a/package.json b/package.json index 44d87f9..8177ba8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/capture/community.css b/scripts/capture/community.css new file mode 100644 index 0000000..39c133c --- /dev/null +++ b/scripts/capture/community.css @@ -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); +} diff --git a/scripts/capture/community.html b/scripts/capture/community.html new file mode 100644 index 0000000..118eefb --- /dev/null +++ b/scripts/capture/community.html @@ -0,0 +1,12 @@ + + + + + Ninelands community images + + + +
+ + + diff --git a/scripts/capture/community.mjs b/scripts/capture/community.mjs new file mode 100644 index 0000000..fe53ddc --- /dev/null +++ b/scripts/capture/community.mjs @@ -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() +} diff --git a/scripts/capture/community.ts b/scripts/capture/community.ts new file mode 100644 index 0000000..fe61086 --- /dev/null +++ b/scripts/capture/community.ts @@ -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 = { + HIVEMOTHER: 'free', + HIVEWORKER: 'free', + DRONE: '10 gold', + HIVESCOUT: '15 gold', +} +const RULE: Record = { + 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 { + const def = UNIT_DEFS[unit] + const mid = (n - 1) / 2 + const out = new Set() + 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 = { + 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()