feat(sasch40): add printable medieval retro A4 proclamation flyer PDF with story and QR code

This commit is contained in:
Moritz Graf
2026-09-25 10:07:03 +02:00
parent bf15c2e9cb
commit c91ab16b9b
5 changed files with 1296 additions and 0 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,663 @@
import fs from 'fs';
import path from 'path';
import puppeteer from 'puppeteer-core';
const BASE_DIR = '/Users/moritz/src/infrapuzzle/projects/sasch40';
const ARTIFACTS_DIR = '/Users/moritz/.gemini/antigravity/brain/26049ae8-3106-42e0-922f-356dbbfe162a';
// Helper to get base64 data URI
function getBase64Image(filePath) {
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(BASE_DIR, filePath);
if (!fs.existsSync(fullPath)) {
console.warn(`File not found: ${fullPath}`);
return '';
}
const ext = path.extname(fullPath).toLowerCase();
let mime = 'image/jpeg';
if (ext === '.png') mime = 'image/png';
if (ext === '.svg') mime = 'image/svg+xml';
const data = fs.readFileSync(fullPath);
return `data:${mime};base64,${data.toString('base64')}`;
}
async function generatePDF() {
console.log('🎨 Generating printable Mittelalter Retro PDF for Saschas Kronkorken Kreuzzug...');
// 1. Assets
const saschaPortrait = getBase64Image('frontend/public/assets/characters/sascha/portrait_v2.jpg');
const qrCode = getBase64Image('qrcode_game.png');
// Mini avatars for entourage strip
const entourage = [
{ name: 'Königin Verena', img: getBase64Image('frontend/public/assets/characters/verena/portrait_v2.jpg') },
{ name: 'Saskia', img: getBase64Image('frontend/public/assets/characters/saskia/portrait_v2.jpg') },
{ name: 'Maja', img: getBase64Image('frontend/public/assets/characters/maja/portrait_v2.jpg') },
{ name: 'Miriam', img: getBase64Image('frontend/public/assets/characters/miriam/portrait_v2.jpg') },
{ name: 'Markus (Vater)', img: getBase64Image('frontend/public/assets/characters/markus/portrait.jpg') },
{ name: 'NAT (Josef)', img: getBase64Image('frontend/public/assets/characters/nat/portrait_v2.jpg') },
{ name: 'Lutz (ZTUL)', img: getBase64Image('frontend/public/assets/characters/lutz/portrait_v2.jpg') },
{ name: 'Armend', img: getBase64Image('frontend/public/assets/characters/armend/portrait_v2.jpg') },
{ name: 'Amy', img: getBase64Image('frontend/public/assets/characters/amy/portrait.jpg') },
{ name: 'Mane', img: getBase64Image('frontend/public/assets/characters/mane/portrait_v2.jpg') },
{ name: 'Hansi', img: getBase64Image('frontend/public/assets/characters/hansi/portrait_v2.jpg') },
{ name: 'Mario', img: getBase64Image('frontend/public/assets/characters/mario/portrait_v2.jpg') },
{ name: 'Flo Maier', img: getBase64Image('frontend/public/assets/characters/flo_maier/portrait_v2.jpg') },
{ name: 'Pü', img: getBase64Image('frontend/public/assets/characters/pue/portrait_v2.jpg') },
{ name: 'Viktor', img: getBase64Image('frontend/public/assets/characters/viktor/portrait_v2.jpg') }
];
const htmlContent = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Saschas Kronkorken Kreuzzug – Proklamation zum 40. Geburtstag</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Press+Start+2P&family=Cinzel:wght@700&display=swap" rel="stylesheet">
<style>
@page {
size: A4 portrait;
margin: 0;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 210mm;
height: 297mm;
margin: 0;
padding: 6mm;
background: #1e0f07;
font-family: 'MedievalSharp', Georgia, serif;
color: #2b1810;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
display: flex;
justify-content: center;
align-items: center;
}
/* Heavy Carved Oak & Gold Outer Frame */
.outer-scroll-frame {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #3d2112 0%, #200f07 50%, #452414 100%);
padding: 3.5mm;
border: 4px solid #b45309;
box-shadow: 0 0 25px rgba(0, 0, 0, 0.85);
position: relative;
display: flex;
flex-direction: column;
}
/* Antique Parchment Inner Page */
.parchment-sheet {
width: 100%;
height: 100%;
background:
radial-gradient(circle at 50% 15%, rgba(255, 255, 255, 0.45) 0%, transparent 60%),
radial-gradient(circle at 50% 85%, rgba(138, 77, 30, 0.2) 0%, transparent 70%),
linear-gradient(180deg, #fbf5e6 0%, #f6e8cc 35%, #edd7ae 75%, #e2c694 100%);
border: 3px double #b45309;
outline: 2px solid #d4af37;
padding: 5mm 6mm;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
box-shadow: inset 0 0 35px rgba(115, 60, 18, 0.35);
}
/* Corner Golden Brackets */
.corner {
position: absolute;
width: 20px;
height: 20px;
z-index: 10;
}
.c-tl { top: 2.5mm; left: 2.5mm; border-top: 3px solid #d4af37; border-left: 3px solid #d4af37; }
.c-tr { top: 2.5mm; right: 2.5mm; border-top: 3px solid #d4af37; border-right: 3px solid #d4af37; }
.c-bl { bottom: 2.5mm; left: 2.5mm; border-bottom: 3px solid #d4af37; border-left: 3px solid #d4af37; }
.c-br { bottom: 2.5mm; right: 2.5mm; border-bottom: 3px solid #d4af37; border-right: 3px solid #d4af37; }
/* Header Section */
.royal-header {
text-align: center;
margin-bottom: 2mm;
}
.proclamation-badge {
font-family: 'Press Start 2P', monospace;
font-size: 7.5pt;
color: #92400e;
letter-spacing: 1.5px;
margin-bottom: 1.5mm;
text-transform: uppercase;
display: inline-block;
padding: 1.2mm 3.5mm;
background: rgba(212, 175, 55, 0.2);
border: 1.5px solid #b45309;
border-radius: 4px;
}
.main-game-title {
font-family: 'Press Start 2P', monospace;
font-size: 15pt;
color: #451a03;
text-shadow: 2px 2px 0px #ffd700, 3px 3px 0px #78350f;
line-height: 1.35;
margin-bottom: 1.5mm;
letter-spacing: 0.5px;
}
.main-subtitle {
font-family: 'Cinzel', serif;
font-size: 11pt;
font-weight: 700;
color: #78350f;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.heraldic-divider {
margin: 2mm 0 3mm 0;
display: flex;
align-items: center;
justify-content: center;
gap: 3mm;
color: #b45309;
font-size: 9pt;
}
.heraldic-line {
flex: 1;
height: 1.5px;
background: linear-gradient(90deg, transparent, #b45309, #d4af37, #b45309, transparent);
}
/* Hero Row: King Portrait + Chronicle Lore */
.hero-lore-row {
display: flex;
gap: 4mm;
align-items: stretch;
margin-bottom: 2.5mm;
}
.king-portrait-card {
width: 50mm;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
background: #fbf5e6;
padding: 2.5mm;
border: 2px solid #b45309;
box-shadow: 0 4px 12px rgba(92, 51, 23, 0.35);
border-radius: 3px;
}
.king-portrait-frame {
width: 100%;
height: 44mm;
border: 3px solid #d4af37;
outline: 2px solid #78350f;
overflow: hidden;
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
background: #111;
}
.king-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
image-rendering: pixelated;
}
.king-name-tag {
font-family: 'Press Start 2P', monospace;
font-size: 6pt;
color: #78350f;
margin-top: 2mm;
text-align: center;
line-height: 1.3;
}
.king-sub-tag {
font-size: 7.5pt;
color: #5c3010;
text-align: center;
font-style: italic;
margin-top: 0.8mm;
}
/* Lore Narrative Box */
.lore-narrative-box {
flex: 1;
background: rgba(255, 255, 255, 0.45);
border: 1.5px solid #d4af37;
padding: 2.5mm 3.5mm;
border-radius: 3px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: inset 0 0 15px rgba(212, 175, 55, 0.15);
}
.lore-title {
font-family: 'Cinzel', serif;
font-size: 10.5pt;
font-weight: 700;
color: #78350f;
border-bottom: 1.5px solid #d4af37;
padding-bottom: 0.8mm;
margin-bottom: 1.5mm;
display: flex;
justify-content: space-between;
}
.lore-p {
font-size: 8.5pt;
line-height: 1.32;
color: #2b1810;
margin-bottom: 1.5mm;
text-align: justify;
}
.lore-p strong {
color: #78350f;
}
.lore-highlight {
background: rgba(212, 175, 55, 0.18);
border-left: 3px solid #b45309;
padding: 1.2mm 2mm;
font-size: 8pt;
line-height: 1.3;
color: #522504;
font-weight: bold;
}
/* Quests Grid */
.quests-section {
margin-bottom: 2.5mm;
}
.section-ribbon {
font-family: 'Press Start 2P', monospace;
font-size: 7pt;
color: #451a03;
text-align: center;
margin-bottom: 2mm;
letter-spacing: 0.5px;
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent);
padding: 0.8mm 0;
}
.quests-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2mm;
}
.quest-card {
background: rgba(255, 255, 255, 0.55);
border: 1.5px solid #b45309;
border-radius: 3px;
padding: 2mm 2.5mm;
box-shadow: 0 2px 6px rgba(115, 60, 18, 0.15);
}
.quest-card-header {
font-family: 'Press Start 2P', monospace;
font-size: 5.5pt;
color: #78350f;
margin-bottom: 1mm;
display: flex;
align-items: center;
gap: 1.5mm;
}
.quest-card-desc {
font-size: 7.5pt;
line-height: 1.25;
color: #381c0c;
}
.quest-card-desc strong {
color: #78350f;
}
/* Action & QR Code Section */
.qr-action-banner {
background: linear-gradient(135deg, #3d1f0f 0%, #2a140a 100%);
color: #fef3c7;
border: 3px solid #d4af37;
border-radius: 4px;
padding: 2.5mm 3.5mm;
display: flex;
gap: 4mm;
align-items: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.45);
margin-bottom: 2.2mm;
}
.qr-frame {
background: #fef3c7;
padding: 1.8mm;
border: 2px solid #ffd700;
border-radius: 3px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
.qr-img {
width: 29mm;
height: 29mm;
display: block;
image-rendering: pixelated;
}
.qr-tag {
font-family: 'Press Start 2P', monospace;
font-size: 4.8pt;
color: #451a03;
margin-top: 1.2mm;
font-weight: bold;
}
.qr-instructions {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.qr-action-title {
font-family: 'Press Start 2P', monospace;
font-size: 8pt;
color: #ffd700;
text-shadow: 1px 1px 0 #000;
line-height: 1.3;
margin-bottom: 1.5mm;
}
.qr-steps {
list-style: none;
font-size: 7.5pt;
line-height: 1.35;
color: #fde68a;
}
.qr-steps li {
margin-bottom: 0.8mm;
display: flex;
align-items: center;
gap: 1.8mm;
}
.qr-step-num {
font-family: 'Press Start 2P', monospace;
font-size: 5pt;
background: #ffd700;
color: #451a03;
padding: 0.6mm 1.2mm;
border-radius: 2px;
font-weight: bold;
}
.qr-url-pill {
margin-top: 1.2mm;
display: inline-block;
font-family: 'Press Start 2P', monospace;
font-size: 6pt;
color: #38bdf8;
background: rgba(0, 0, 0, 0.45);
border: 1px dashed #38bdf8;
padding: 1mm 2mm;
border-radius: 3px;
}
/* Entourage Cast Strip */
.entourage-strip {
background: rgba(255, 255, 255, 0.4);
border: 1px solid #d4af37;
border-radius: 3px;
padding: 1.5mm 2mm;
display: flex;
flex-direction: column;
gap: 1mm;
}
.entourage-label {
font-family: 'Press Start 2P', monospace;
font-size: 4.8pt;
color: #78350f;
text-align: center;
letter-spacing: 0.5px;
}
.entourage-avatars {
display: flex;
justify-content: space-between;
align-items: center;
}
.entourage-avatar-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6mm;
}
.entourage-avatar-img {
width: 6.8mm;
height: 6.8mm;
border-radius: 50%;
border: 1.2px solid #b45309;
outline: 1px solid #d4af37;
object-fit: cover;
image-rendering: pixelated;
}
.entourage-name {
font-family: 'Press Start 2P', monospace;
font-size: 3pt;
color: #451a03;
white-space: nowrap;
text-align: center;
}
/* Royal Seal / Sign-off */
.royal-signoff {
text-align: center;
font-size: 6.5pt;
color: #78350f;
margin-top: 1mm;
font-style: italic;
letter-spacing: 0.3px;
}
</style>
</head>
<body>
<div class="outer-scroll-frame">
<div class="parchment-sheet">
<!-- 4 Golden Corner Accents -->
<div class="corner c-tl"></div>
<div class="corner c-tr"></div>
<div class="corner c-bl"></div>
<div class="corner c-br"></div>
<!-- Header -->
<header class="royal-header">
<div class="proclamation-badge">👑 KÖNIGLICHE PROKLAMATION ZUM 40. GEBURTSTAG 👑</div>
<h1 class="main-game-title">SASCHAS KRONKORKEN KREUZZUG</h1>
<div class="main-subtitle">40 JAHRE BASS & GERSTENSAFT • HERZOGTUM HAUMDAUCHER</div>
<div class="heraldic-divider">
<div class="heraldic-line"></div>
<span>⚜️</span>
<span>✦</span>
<span>👑</span>
<span>✦</span>
<span>🍻</span>
<span>✦</span>
<span>⚔️</span>
<span>✦</span>
<span>⚜️</span>
<div class="heraldic-line"></div>
</div>
</header>
<!-- Hero Lore: King Sascha I. Portrait + Story -->
<div class="hero-lore-row">
<div class="king-portrait-card">
<div class="king-portrait-frame">
<img src="${saschaPortrait}" alt="König Sascha I." class="king-img" />
</div>
<div class="king-name-tag">KÖNIG SASCHA I.</div>
<div class="king-sub-tag">Der 40-jährige Imperator</div>
</div>
<div class="lore-narrative-box">
<div class="lore-title">
<span>📜 DIE CHRONIK DES REICHES</span>
<span>ANNO 2026</span>
</div>
<p class="lore-p">
<strong>Hört, hört, wertes Feiervolk und edle Zechkumpanen!</strong><br>
Seit nunmehr 40 stolzen Jahren regiert <strong>König Sascha I.</strong> das Herzogtum Haumdaucher mit unerschütterlicher Kraft, seiner legendären dunklen Sonnenbrille und wummernden <strong>135-BPM-Techno-Bässen</strong> aus den Schlossmauern!
</p>
<p class="lore-p">
Doch kurz vor dem heutigen Thronjubiläum schlug das finstere Schicksal zu: Die königliche Schatzkammer wurde geplündert! Sämtliche <strong>40 geheiligten Kronkorken</strong> des Reiches liegen über ganz Haumdaucher verstreut – und Saschas geliebte Schwestern sind in den Wolkentürmen verschollen!
</p>
<div class="lore-highlight">
🚨 Die Lage ist dramatisch: Ohne Kronkorken kein kühles Helles, ohne Helles kein Bass – und ohne Bass keine Jubiläumsfeier! Das Schicksal der Regentschaft liegt in deinen Händen!
</div>
</div>
</div>
<!-- Quests Section: 4 Core Adventures -->
<div class="quests-section">
<div class="section-ribbon">⚔️ DEINE KÖNIGLICHEN MISSIONEN IM SPIEL ⚔️</div>
<div class="quests-grid">
<div class="quest-card">
<div class="quest-card-header">
<span>☁️</span>
<span>AKT II: SCHWESTERN-RETTUNG</span>
</div>
<p class="quest-card-desc">
Hüpfe im Arcade-Doodle-Jump von Wolke zu Wolke hinauf und befreie Saschas Schwestern <strong>Saskia, Maja & Miriam</strong> aus ihren Wolkentürmen!
</p>
</div>
<div class="quest-card">
<div class="quest-card-header">
<span>🍻</span>
<span>AKT III: DER SCHANKRAUM</span>
</div>
<p class="quest-card-desc">
Erkunde das Wirtshaus: Triff <strong>Königin Verena</strong> auf der Galerie, <strong>Lutz</strong> im Holzbottich, <strong>Armend</strong> am Schmiede-Amboss & öffne die geheime Luke zum VIP-Bunker!
</p>
</div>
<div class="quest-card">
<div class="quest-card-header">
<span>🐗</span>
<span>HOF-DUELLE: NAT & MARKUS</span>
</div>
<p class="quest-card-desc">
Besiege Hof-Barde <strong>NAT</strong> im <em>Wildschwein-Schnick-Schnack-Schnuck</em> und bluffe <strong>Markus (Vater)</strong> beim taiwanesischen <em>Liar's Dice (吹牛)</em> ab!
</p>
</div>
<div class="quest-card">
<div class="quest-card-header">
<span>🎸</span>
<span>AKT IV & V: KONZERT & URKUNDE</span>
</div>
<p class="quest-card-desc">
Rocke an der Minnesänger-Laute (Mittelalter Guitar Hero) mit <strong>Ritter Hansi</strong> die Schlossbühne und erhalte deine persönliche <strong>Highscore-Thronurkunde</strong>!
</p>
</div>
</div>
</div>
<!-- Action & QR Code Section -->
<div class="qr-action-banner">
<div class="qr-frame">
<img src="${qrCode}" alt="QR Code zum Spiel" class="qr-img" />
<span class="qr-tag">👑 SCAN & PLAY 👑</span>
</div>
<div class="qr-instructions">
<div class="qr-action-title">📱 JETZT SCANNEN & DIREKT MITFEIERN!</div>
<ul class="qr-steps">
<li>
<span class="qr-step-num">1</span>
<span>Kamera-App des Smartphones auf den QR-Code richten</span>
</li>
<li>
<span class="qr-step-num">2</span>
<span>Tippe auf den Link oder öffne direkt im Browser</span>
</li>
<li>
<span class="qr-step-num">3</span>
<span>Lautstärke aufdrehen für 135-BPM-Mittelalter-Soundtrack!</span>
</li>
<li>
<span class="qr-step-num">4</span>
<span>Kronkorken sammeln & den Party-Highscore knacken!</span>
</li>
</ul>
<div class="qr-url-pill">🌐 https://sasch40.haumdaucher.de</div>
</div>
</div>
<!-- Royal Entourage Strip -->
<div class="entourage-strip">
<div class="entourage-label">⚜️ DAS HAUMDAUCHER GEFOLGE – ALLE CHARAKTERE IM SPIEL ⚜️</div>
<div class="entourage-avatars">
${entourage.map(c => `
<div class="entourage-avatar-item" title="${c.name}">
<img src="${c.img}" alt="${c.name}" class="entourage-avatar-img" />
<span class="entourage-name">${c.name.split(' ')[0]}</span>
</div>
`).join('')}
</div>
</div>
<!-- Royal Seal Sign-off -->
<div class="royal-signoff">
Besiegelt durch das königliche Hofamt Haumdaucher zum 40. Geburtstag von König Sascha I. • Anno 2026 • Code & Pixel von Mop
</div>
</div>
</div>
</body>
</html>
`;
const htmlPath = path.join(BASE_DIR, 'Saschas_Kronkorken_Kreuzzug_Jubilaeum.html');
fs.writeFileSync(htmlPath, htmlContent, 'utf-8');
console.log(`✅ Saved HTML template to ${htmlPath}`);
// Launch Puppeteer to render pixel-perfect A4 PDF
const browser = await puppeteer.launch({
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setContent(htmlContent, { waitUntil: 'load', timeout: 25000 });
try {
await page.evaluateHandle('document.fonts.ready');
} catch (e) {}
await new Promise(r => setTimeout(r, 1000));
// Output paths
const pdfFileName = 'Saschas_Kronkorken_Kreuzzug_Jubilaeum.pdf';
const outPdf1 = path.join(BASE_DIR, pdfFileName);
const outPdf2 = path.join(BASE_DIR, 'frontend/public', pdfFileName);
const outPdf3 = path.join(ARTIFACTS_DIR, pdfFileName);
console.log('🖨️ Rendering A4 PDF...');
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '0mm', right: '0mm', bottom: '0mm', left: '0mm' },
preferCSSPageSize: true
});
fs.writeFileSync(outPdf1, pdfBuffer);
fs.writeFileSync(outPdf2, pdfBuffer);
fs.writeFileSync(outPdf3, pdfBuffer);
console.log(`✅ PDF written to:\n 1. ${outPdf1}\n 2. ${outPdf2}\n 3. ${outPdf3}`);
// Also take a high-res PNG preview of the printed page for visual review!
await page.setViewport({ width: 1240, height: 1754, deviceScaleFactor: 2 });
const previewPng = path.join(ARTIFACTS_DIR, 'Saschas_Kronkorken_Kreuzzug_Druck_Vorschau.png');
await page.screenshot({ path: previewPng, fullPage: true });
console.log(`📸 Rendered high-res preview screenshot to ${previewPng}`);
await browser.close();
console.log('🎉 PDF generation complete!');
}
generatePDF().catch(err => {
console.error('❌ Error generating PDF:', err);
process.exit(1);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB