card game

This commit is contained in:
2026-05-31 14:39:45 -05:00
parent 5cc62433bd
commit 6abe253547
18 changed files with 2713 additions and 20 deletions

View File

@@ -0,0 +1,247 @@
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
/* ============================================================
TROX Scorepad Generator (PDF, parametrisierbar)
------------------------------------------------------------
Ausgabe als PDF. Frei einstellbar: Format, Ausrichtung,
Spalten/Zeilen an Cards, Spielerzahl, Seitenrand.
AUFRUF:
node generate_scorepad.js [format] [orientation] [cols] [rows] [players] [margin_in]
Beispiele:
node generate_scorepad.js -> both, portrait, 2x3, 6 Spieler
node generate_scorepad.js letter portrait 1 2 6 -> 2 große Cards/Seite
node generate_scorepad.js a4 landscape 3 2 4 -> A4 quer, 6 Cards, 4 Spieler
node generate_scorepad.js letter landscape 2 2 6 0.4
Parameter:
format letter | a4 | both (Standard: both)
orientation portrait | landscape (Standard: portrait)
cols Spalten an Cards (Standard: 2)
rows Zeilen an Cards (Standard: 3)
players Spieler pro Card (Standard: 6)
margin_in Seitenrand in Zoll (Standard: 0.4)
Eigenschaften:
- "Position"-Zeile WEISS (zum Eintragen der Endplatzierung, z.B. 1. / 3.)
- "Name" links oben in der Kopfzelle, dezent/schwach gedruckt
- TROX-Titelbalken weiß mit goldenen Verzierungen (nicht schwarz)
- Bid-Kästchen kräftig gedruckt
============================================================ */
// ---------- CONFIG (Defaults; per CLI überschreibbar) ----------
const CONFIG = {
cols: 2,
rows: 3,
players: 6,
marginIn: 0.4,
gapIn: 0.14,
orientation: 'portrait',
};
const argv = process.argv.slice(2);
const MODE = (argv[0] || 'both').toLowerCase();
if (argv[1] && /^(portrait|landscape)$/.test(argv[1])) CONFIG.orientation = argv[1];
if (argv[2] !== undefined && !isNaN(+argv[2])) CONFIG.cols = Math.max(1, parseInt(argv[2]));
if (argv[3] !== undefined && !isNaN(+argv[3])) CONFIG.rows = Math.max(1, parseInt(argv[3]));
if (argv[4] !== undefined && !isNaN(+argv[4])) CONFIG.players = Math.max(2, parseInt(argv[4]));
if (argv[5] !== undefined && !isNaN(+argv[5])) CONFIG.marginIn = Math.max(0, parseFloat(argv[5]));
const PT = 72; // Punkte pro Zoll (PDF-Einheit)
// Palette
const C = {
dark: '#111827',
gold: '#AA7C11',
goldLt: '#D4AF37',
goldPale:'#F3E5AB',
ink: '#1F2937',
line: '#D1D5DB',
zebra: '#FAFAFA',
nameTx: '#C4C9D2', // schwach gedruckter Name
bidStroke: '#6B7280', // kräftiger als vorher (#cbd5e1)
grey: '#9CA3AF',
};
const ROUNDS = [
'R1 (1 Card)', 'R2 (2 Cards)', 'R3 (3 Cards)', 'R4 (4 Cards)', 'R5 (5 Cards)',
'R6 (5 Cards)', 'R7 (4 Cards)', 'R8 (3 Cards)', 'R9 (2 Cards)', 'R10 (1 Card)'
];
// ============================================================
// Eine Scorecard zeichnen (Koordinaten in PDF-Punkten)
// ============================================================
function drawCard(doc, x, y, w, h, players) {
const sc = Math.min(Math.max(w / 270, 0.7), 2.2); // Skalenfaktor (270pt ~ Referenz)
const titleH = 24 * sc;
const headerH = 22 * sc;
const bodyTop = titleH + headerH;
const nRows = ROUNDS.length + 1; // + Position
const rowH = (h - bodyTop) / nRows;
const labelColW = w * 0.26;
const playerColW = (w - labelColW) / players;
const fTitle = 12 * sc, fHeader = 7.5 * sc, fRound = 7 * sc,
fName = 6.8 * sc, fPos = 8 * sc;
const bidW = 12 * sc, bidH = 9 * sc;
const radius = 6 * sc;
// --- Outer frame (weißer Hintergrund) ---
doc.save();
doc.roundedRect(x, y, w, h, radius).fillAndStroke('#ffffff', C.ink);
doc.lineWidth(1.2).strokeColor(C.ink).roundedRect(x, y, w, h, radius).stroke();
doc.restore();
// --- Titelbalken: WEISS mit goldenen Verzierungen ---
doc.save();
// dünne goldene Linien ober-/unterhalb + Mittelornamente
const tcy = y + titleH / 2;
doc.font('Helvetica-Bold').fontSize(fTitle).fillColor(C.gold)
.text('T R O X', x, tcy - fTitle * 0.62, { width: w, align: 'center', characterSpacing: 2 * sc });
// Verzierungslinien links/rechts vom Titel
const titleTextW = doc.widthOfString('T R O X', { characterSpacing: 2 * sc });
const cxp = x + w / 2;
const ornGap = titleTextW / 2 + 12 * sc;
const ornLen = Math.max(8 * sc, (w / 2) - ornGap - 10 * sc);
doc.lineWidth(1 * sc).strokeColor(C.goldLt).opacity(0.9);
// links
doc.moveTo(x + 10 * sc, tcy).lineTo(x + 10 * sc + ornLen, tcy).stroke();
// rechts
doc.moveTo(x + w - 10 * sc - ornLen, tcy).lineTo(x + w - 10 * sc, tcy).stroke();
// kleine Rauten an den Linienenden
const diamond = (dx) => {
const r = 2.4 * sc;
doc.opacity(1).fillColor(C.goldLt)
.moveTo(dx, tcy - r).lineTo(dx + r, tcy).lineTo(dx, tcy + r).lineTo(dx - r, tcy).fill();
};
diamond(x + 10 * sc); diamond(x + 10 * sc + ornLen);
diamond(x + w - 10 * sc); diamond(x + w - 10 * sc - ornLen);
doc.opacity(1);
// feine goldene Trennlinie unter dem Titel
doc.lineWidth(0.8 * sc).strokeColor(C.goldLt)
.moveTo(x, y + titleH).lineTo(x + w, y + titleH).stroke();
doc.restore();
// --- Kopfzeile ---
const hy = y + titleH;
doc.save();
doc.font('Helvetica-Bold').fontSize(fHeader).fillColor(C.ink)
.text('Round (Cards)', x + 6 * sc, hy + headerH / 2 - fHeader * 0.55, { width: labelColW - 8 * sc });
// Label-Spalten-Trennlinie über volle Höhe
doc.lineWidth(1).strokeColor(C.ink)
.moveTo(x + labelColW, hy).lineTo(x + labelColW, y + h).stroke();
for (let p = 0; p < players; p++) {
const px = x + labelColW + p * playerColW;
// Kopfzelle: heller Hintergrund
doc.rect(px, hy, playerColW, headerH).fillColor('#F3F4F6').fill();
doc.lineWidth(0.5).strokeColor(C.ink).rect(px, hy, playerColW, headerH).stroke();
// "Name" links oben, schwach
doc.font('Helvetica-Oblique').fontSize(fName).fillColor(C.nameTx)
.text('Name', px + 4 * sc, hy + 3 * sc, { width: playerColW - 6 * sc });
if (p < players - 1) {
doc.lineWidth(0.5).strokeColor(C.ink)
.moveTo(px + playerColW, hy).lineTo(px + playerColW, y + h).stroke();
}
}
doc.restore();
// --- Runden-Zeilen ---
for (let r = 0; r < ROUNDS.length; r++) {
const ry = y + bodyTop + r * rowH;
doc.save();
if (r % 2 === 1) {
doc.rect(x + labelColW, ry, w - labelColW, rowH).fillColor(C.zebra).fill();
}
doc.lineWidth(0.5).strokeColor(C.line).moveTo(x, ry).lineTo(x + w, ry).stroke();
doc.font('Helvetica').fontSize(fRound).fillColor(C.ink)
.text(ROUNDS[r], x + 6 * sc, ry + rowH / 2 - fRound * 0.55, { width: labelColW - 8 * sc });
// Bid-Kästchen je Spieler KRÄFTIGER Strich
for (let p = 0; p < players; p++) {
const px = x + labelColW + p * playerColW;
doc.lineWidth(1.1 * sc).strokeColor(C.bidStroke)
.roundedRect(px + 3 * sc, ry + 2.5 * sc, bidW, bidH, 1.5 * sc).stroke();
}
doc.restore();
}
// --- Position-Zeile: WEISS (zum Eintragen der Platzierung) ---
const py = y + bodyTop + ROUNDS.length * rowH;
doc.save();
// weißer Hintergrund (kein dunkler Balken mehr)
doc.rect(x, py, w, rowH).fillColor('#ffffff').fill();
// kräftige Trennlinie oben drüber
doc.lineWidth(1).strokeColor(C.ink).moveTo(x, py).lineTo(x + w, py).stroke();
doc.font('Helvetica-Bold').fontSize(fPos).fillColor(C.gold)
.text('Position', x + 6 * sc, py + rowH / 2 - fPos * 0.55, { width: labelColW - 8 * sc });
// vertikale Spaltenlinien in der Position-Zeile
for (let p = 1; p < players; p++) {
const px = x + labelColW + p * playerColW;
doc.lineWidth(0.5).strokeColor(C.line).moveTo(px, py).lineTo(px, py + rowH).stroke();
}
doc.restore();
}
// ============================================================
// Eine Seite zeichnen
// ============================================================
function drawPage(doc, pageW, pageH, label) {
const margin = CONFIG.marginIn * PT;
const gap = CONFIG.gapIn * PT;
const headerSpace = 22;
const { cols, rows: rowsN, players } = CONFIG;
const cardW = (pageW - 2 * margin - (cols - 1) * gap) / cols;
const cardH = (pageH - 2 * margin - headerSpace - (rowsN - 1) * gap) / rowsN;
// Seitenkopf
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.dark)
.text('TROX SCOREPAD', margin, margin, { characterSpacing: 1.5, continued: false });
const gpp = cols * rowsN;
doc.font('Helvetica').fontSize(7).fillColor(C.grey)
.text(`${gpp} game(s) / ${label} ${CONFIG.orientation} · ${players} players · box = Bid · cell = running total · exact bid = +10`,
margin, margin + 1, { width: pageW - 2 * margin, align: 'right' });
for (let r = 0; r < rowsN; r++) {
for (let c = 0; c < cols; c++) {
const cx = margin + c * (cardW + gap);
const cy = margin + headerSpace + r * (cardH + gap);
drawCard(doc, cx, cy, cardW, cardH, players);
}
}
}
// ============================================================
const FORMATS = {
letter: { size: 'LETTER', label: 'Letter' },
a4: { size: 'A4', label: 'A4' },
};
const outputDir = path.join(__dirname, 'scorepad_export');
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
function emit(key) {
const fmt = FORMATS[key];
const doc = new PDFDocument({
size: fmt.size,
layout: CONFIG.orientation,
margin: 0,
info: { Title: `TROX Scorepad (${fmt.label})`, Author: 'Bay Area Affiliates' },
});
const file = `trox_scorepad_${key}_${CONFIG.orientation}_${CONFIG.cols}x${CONFIG.rows}_${CONFIG.players}p.pdf`;
const outPath = path.join(outputDir, file);
doc.pipe(fs.createWriteStream(outPath));
drawPage(doc, doc.page.width, doc.page.height, fmt.label);
doc.end();
console.log(` ${fmt.label} ${CONFIG.orientation} -> scorepad_export/${file}`);
}
console.log(`TROX Scorepad (PDF): ${CONFIG.cols}x${CONFIG.rows} Cards, ${CONFIG.players} Spieler, ${CONFIG.orientation}, Rand ${CONFIG.marginIn}"`);
if (MODE === 'letter' || MODE === 'both') emit('letter');
if (MODE === 'a4' || MODE === 'both') emit('a4');
console.log('Fertig.');