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

5
.gitignore vendored
View File

@@ -40,3 +40,8 @@ testem.log
# System files
.DS_Store
Thumbs.db
scorepad_export
karten_export_tgc
karten_export_original
box_export

74
card-game/README.md Normal file
View File

@@ -0,0 +1,74 @@
# TROX Druckpaket & Generator-Skripte
Alles für Prototyp und Serie bei The Game Crafter (TGC) bzw. ähnlichen Portalen.
## Suit-Codes (NEU, englische Logik)
| Suit | Code | Rolle | Symbol |
|---|---|---|---|
| Red | **R** | Permanenter TRUMPF | Crown |
| Yellow | **Y** | Standard | Sun |
| Green | **G** | Standard | Leaf |
| Black | **B** | Standard | Gear |
Kartencodes auf den Karten z.B. `TK-Y4` (Yellow 4), `TK-G7` (Green 7), `TK-R17`, `TK-B12` (Black 12).
## Was ist drin
| Datei / Ordner | Inhalt |
|---|---|
| `trox_card.js` | **Gemeinsames Modul** die EINE Kartenzeichnung. Wird von cards + box genutzt |
| `generate_cards.js` | 80 Kartenvorderseiten (TGC + Original) |
| `generate_back.js` | gemeinsame Rückseite (TGC + Original) |
| `generate_box.js` | Tuckbox: Front mit ECHTEN TROX-Karten (aus trox_card.js) + Box-Netz |
| `generate_scorepad.js` | Scorepad als **PDF** Format/Ausrichtung/Layout/Spielerzahl frei wählbar |
| `make_rules.js` | **Node.js**-Skript der PDF-Anleitung (pdfkit) |
| `svg_to_png.js` | SVG -> PNG Konverter für den TGC-Upload |
| `TROX_Rules_Extended_EN.pdf` | Erweiterte Anleitung mit 3-Spieler-Beispielen |
| `karten_export_tgc/` | 80 Karten + Rückseite, **825x1125 px** (TGC-konform, mit Bleed) |
| `karten_export_original/` | dieselben in **694x1069 px** (Originalmaß) |
| `box_export/` | `trox_box_front.svg` + `trox_box_wrap.svg` |
| `scorepad_export/` | mehrere **PDF**-Varianten (Letter/A4, portrait/landscape, versch. Layouts) |
## Architektur (wichtig)
`trox_card.js` ist die **einzige Quelle** des Kartendesigns:
- `buildCardSVG(colorName, num, opts)` -> komplette Karten-SVG (von generate_cards.js)
- `miniCardGroup(colorName, num, cx, cy, breite, rotation)` -> dieselbe Karte als skalierte Gruppe (von generate_box.js für den Fächer)
Dadurch sind die Karten auf der Box **garantiert identisch** mit den echten Spielkarten nur skaliert und gefächert. Wer das Design ändert (Farben, Symbole, Schrift), ändert NUR `trox_card.js`, und Karten + Box bleiben synchron.
## Skripte ausführen
```bash
npm install pdfkit # einmalig, für make_rules.js
node generate_cards.js # both (TGC + Original) oder: tgc | original
node generate_back.js # both oder: tgc | original
node generate_box.js # both (front + wrap) oder: front | wrap [scale]
node generate_scorepad.js # PDF, both 2x3 6 Spieler [fmt] [orientation] [cols] [rows] [players] [margin]
node make_rules.js # erzeugt die PDF-Anleitung
```
## Druck-Workflow für The Game Crafter
1. **Produkt:** Karten = *Poker Card* (825x1125). Box für 80 Karten = *Poker Tuck Box (90 cards)*.
2. **SVG -> PNG** (TGC nimmt KEIN SVG):
```bash
npm install sharp
node svg_to_png.js karten_export_tgc
node svg_to_png.js box_export
```
3. **Hochladen:** PNGs der 80 Karten + Rückseite in den Deck-Uploader.
4. **Box:** offizielles Stanz-Template der "Poker Tuck Box (90 cards)" von TGC laden und in `generate_box.js` die Konstanten `PANEL_W`, `PANEL_H`, `SPINE`, `FLAP` daran angleichen. Faltlinien (gestrichelt) VOR dem Upload entfernen.
### TGC-Specs (bestätigt)
- **300 DPI**, **nur RGB** (CMYK wird abgelehnt).
- **Bleed** 1/8" (≈37 px), **Safe Zone** 1/4" (75 px) vom Rand.
## Anpassen
- **Kartendesign (Farben/Symbole/Schrift):** nur `trox_card.js`.
- **Welche Karten im Box-Fächer:** `cardFan()` in `generate_box.js` (die vier `miniCardGroup(...)`-Aufrufe).
- **Größe der Box-Karten:** `CARD_SCALE` oben in `generate_box.js` (Standard 0.30) ODER per CLI: `node generate_box.js front 0.26`. Eine Auto-Fit-Sicherung verkleinert den Fächer automatisch, falls er aus dem goldenen Rahmen ragen würde. Weitere Fächer-Regler: `FAN_SPREAD`, `FAN_ARC`, `FAN_TILT`, `FAN_CY`.
- **Scorepad-Layout:** voll parametrisierbar per CLI `node generate_scorepad.js [format] [cols] [rows] [players] [margin_in]`. Weniger Cards/Seite = größere, besser lesbare Cards (Schrift skaliert mit). Beispiel gegen zu kleinen Druck: `node generate_scorepad.js letter 1 2 6 0.5` (2 große Cards, 0.5" Rand). Defaults stehen im CONFIG-Block oben im Skript.
- **Anleitung:** `make_rules.js`, klar in Abschnitte gegliedert.
## Offene Designfrage (für dich)
Im Beispiel B summieren sich die Gebote auf genau die Stichzahl dann können theoretisch alle treffen.
Viele Stichspiele verbieten das ("Screw-the-Dealer"). In der Anleitung ist das als
**optionale Variante "Exact Tension" (Abschnitt 8)** eingebaut. Entscheide, ob das deine Standardregel wird.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
card-game/TROX_scorepad.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,95 @@
const fs = require('fs');
const path = require('path');
/* ============================================================
TROX Kartenrückseite Generator
node generate_back.js tgc | original | both
============================================================ */
const MODE = (process.argv[2] || 'both').toLowerCase();
const SPECS = {
tgc: { width: 825, height: 1125, safe: 75, cornerRadius: 0, dir: 'karten_export_tgc' },
original: { width: 694, height: 1069, safe: 35, cornerRadius: 35, dir: 'karten_export_original' }
};
function buildBack(spec) {
const { width, height, safe, cornerRadius } = spec;
const cx = width / 2, cy = height / 2;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#111827" />
<stop offset="100%" stop-color="#1F2937" />
</linearGradient>
<linearGradient id="goldGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#D4AF37" />
<stop offset="50%" stop-color="#F3E5AB" />
<stop offset="100%" stop-color="#AA7C11" />
</linearGradient>
<g id="pattern-quarter">
<path d="M 0 0 L 150 150 L 0 300 Z" fill="none" stroke="#E53935" stroke-width="2" opacity="0.4"/>
<path d="M 0 0 L 120 120 L 0 240 Z" fill="none" stroke="#FDD835" stroke-width="2" opacity="0.4"/>
<path d="M 0 0 L 90 90 L 0 180 Z" fill="none" stroke="#43A047" stroke-width="2" opacity="0.4"/>
<path d="M 0 0 L 60 60 L 0 120 Z" fill="none" stroke="#2196F3" stroke-width="2" opacity="0.3"/>
</g>
</defs>
<!-- Vollflächiger Hintergrund bis zum Bleed-Rand -->
<rect width="${width}" height="${height}" ${cornerRadius ? `rx="${cornerRadius}"` : ''} fill="url(#bgGradient)" />
<!-- Zierrahmen an der Safe Zone -->
<rect x="${safe}" y="${safe}" width="${width - 2 * safe}" height="${height - 2 * safe}" rx="${Math.max(0, cornerRadius - 15)}" fill="none" stroke="url(#goldGradient)" stroke-width="3" opacity="0.8" />
<rect x="${safe + 10}" y="${safe + 10}" width="${width - 2 * safe - 20}" height="${height - 2 * safe - 20}" rx="${Math.max(0, cornerRadius - 20)}" fill="none" stroke="#ffffff" stroke-width="1" opacity="0.1" />
<!-- Eckornamente (innerhalb Safe Zone) -->
<g transform="translate(${safe}, ${safe})">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<g transform="translate(${width - safe}, ${safe}) scale(-1, 1)">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<g transform="translate(${safe}, ${height - safe}) scale(1, -1)">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<g transform="translate(${width - safe}, ${height - safe}) scale(-1, -1)">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<!-- Zentrales Emblem -->
<g transform="translate(${cx}, ${cy})">
<rect x="-120" y="-120" width="240" height="240" rx="20" fill="none" stroke="url(#goldGradient)" stroke-width="4" transform="rotate(45)" />
<rect x="-100" y="-100" width="200" height="200" rx="15" fill="none" stroke="#ffffff" stroke-width="1" opacity="0.2" transform="rotate(45)" />
<circle cx="0" cy="0" r="70" fill="none" stroke="#E53935" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="60" fill="none" stroke="#FDD835" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="50" fill="none" stroke="#43A047" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="40" fill="none" stroke="#212121" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="30" fill="#111827" stroke="url(#goldGradient)" stroke-width="2" />
<g transform="translate(0, -250)">
<text x="0" y="0" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="900" font-size="36" fill="url(#goldGradient)" text-anchor="middle" letter-spacing="6">TROX</text>
</g>
<g transform="rotate(180) translate(0, -250)">
<text x="0" y="0" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="900" font-size="36" fill="url(#goldGradient)" text-anchor="middle" letter-spacing="6">TROX</text>
</g>
</g>
</svg>`;
}
function generate(variant) {
const spec = SPECS[variant];
const outputDir = path.join(__dirname, spec.dir);
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'trox_card_back.svg'), buildBack(spec));
console.log(` [${variant}] Rückseite -> ${spec.dir}/trox_card_back.svg (${spec.width}x${spec.height})`);
}
console.log('TROX Rückseite wird generiert ...');
if (MODE === 'tgc' || MODE === 'both') generate('tgc');
if (MODE === 'original' || MODE === 'both') generate('original');
console.log('Fertig.');

226
card-game/generate_box.js Normal file
View File

@@ -0,0 +1,226 @@
const fs = require('fs');
const path = require('path');
const { miniCardGroup } = require('./trox_card');
/* ============================================================
TROX Tuck Box Generator
------------------------------------------------------------
Box-Vorderseite zeigt ECHTE TROX-Karten (identisch zu den
Spielkarten, via gemeinsames Modul trox_card.js), gefächert.
Teile der hinteren Karten werden durch Überlappung verdeckt,
der untere Bereich durch das TROX-Logo genau wie gewünscht.
node generate_box.js front | wrap | both
HINWEIS DRUCK (TGC): exakte Stanzkontur "Poker Tuck Box (90 cards)"
von der TGC-Produktseite laden und PANEL_W/PANEL_H/SPINE/FLAP unten
daran angleichen das Artwork skaliert automatisch mit.
============================================================ */
const MODE = (process.argv[2] || 'both').toLowerCase();
const DPI = 300;
const PANEL_W = Math.round(2.55 * DPI); // 765
const PANEL_H = Math.round(3.55 * DPI); // 1065
const SPINE = Math.round(0.90 * DPI); // 270
const FLAP = Math.round(0.55 * DPI); // 165
const BLEED = 37;
const SAFE = 75;
/* ------------------------------------------------------------
FÄCHER-TUNING (hier anpassen):
CARD_SCALE Größe einer Einzelkarte relativ zur Panelbreite.
Höher = größere Karten. Per Kommandozeile überschreibbar:
node generate_box.js front 0.32
FAN_SPREAD wie weit der Fächer auseinandergeht (horizontaler Versatz).
FAN_ARC wie stark der Fächer-Bogen nach unten ausschwingt.
FAN_TILT maximaler Drehwinkel der äußeren Karten (Grad).
FAN_CY vertikale Lage des Fächermittelpunkts (Anteil der Höhe).
Eine eingebaute Sicherung verkleinert den Fächer automatisch, falls er
sonst aus dem goldenen Rahmen (Safe Zone) herausragen würde.
------------------------------------------------------------ */
const CLI_SCALE = parseFloat(process.argv[3]);
const CARD_SCALE = !isNaN(CLI_SCALE) ? CLI_SCALE : 0.30; // vorher faktisch 0.42 -> jetzt kleiner
const FAN_SPREAD = 0.50;
const FAN_ARC = 0.11;
const FAN_TILT = 24;
const FAN_CY = 0.34;
const DEFS = `
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#111827" />
<stop offset="100%" stop-color="#1F2937" />
</linearGradient>
<linearGradient id="goldGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#D4AF37" />
<stop offset="50%" stop-color="#F3E5AB" />
<stop offset="100%" stop-color="#AA7C11" />
</linearGradient>
</defs>`;
// Gefächerte ECHTE Karten.
// cx,cy = Fächermittelpunkt
// cardW = Breite einer Karte in px
// maxRight= rechte Grenze (Safe-Zone-Rand), darf nicht überschritten werden
// Gibt {svg, fittedCardW} zurück; verkleinert cardW automatisch, falls nötig.
function cardFan(cx, cy, cardW, panelW) {
const REF_RATIO = 1069 / 694; // Höhe/Breite einer Karte
const tiltRad = FAN_TILT * Math.PI / 180;
// Innenkante des goldenen Rahmens (Safe Zone) plus kleiner Puffer
const margin = SAFE + 6;
const leftLimit = margin;
const rightLimit = panelW - margin;
// Funktion: rechte Außenkante des Fächers für ein gegebenes cardW
function rightEdge(cw) {
const dx = cw * FAN_SPREAD;
const cardH = cw * REF_RATIO;
// halbe Diagonale der gedrehten Außenkarte (worst case Ausdehnung)
const halfSpanX = (Math.abs(Math.cos(tiltRad)) * cw + Math.abs(Math.sin(tiltRad)) * cardH) / 2;
const outerCenterX = cx + 1.55 * dx;
return outerCenterX + halfSpanX;
}
function leftEdge(cw) {
const dx = cw * FAN_SPREAD;
const cardH = cw * REF_RATIO;
const halfSpanX = (Math.abs(Math.cos(tiltRad)) * cw + Math.abs(Math.sin(tiltRad)) * cardH) / 2;
const outerCenterX = cx - 1.55 * dx;
return outerCenterX - halfSpanX;
}
// Auto-Fit: cardW so weit verkleinern, bis beide Außenkanten im Rahmen liegen
let cw = cardW;
let guard = 0;
while ((rightEdge(cw) > rightLimit || leftEdge(cw) < leftLimit) && guard < 200) {
cw *= 0.98;
guard++;
}
const dx = cw * FAN_SPREAD;
const dy = cw * FAN_ARC;
const t = FAN_TILT;
const svg = `
<g>
${miniCardGroup('Gruen', '7', cx - 1.55 * dx, cy + 1.7 * dy, cw, -t)}
${miniCardGroup('Schwarz', '12', cx - 0.52 * dx, cy + 0.3 * dy, cw, -t / 3)}
${miniCardGroup('Rot', '17', cx + 0.52 * dx, cy + 0.3 * dy, cw, t / 3)}
${miniCardGroup('Gelb', '4', cx + 1.55 * dx, cy + 1.7 * dy, cw, t)}
</g>`;
return { svg, fittedCardW: cw };
}
function frontContent(w, h) {
const cx = w / 2;
// gewünschte Kartengröße aus CARD_SCALE; Auto-Fit hält sie im Rahmen
const wantCardW = w * CARD_SCALE;
const fan = cardFan(cx, h * FAN_CY, wantCardW, w);
return `
<rect width="${w}" height="${h}" fill="url(#bgGradient)" />
<rect x="${SAFE}" y="${SAFE}" width="${w - 2 * SAFE}" height="${h - 2 * SAFE}" rx="14" fill="none" stroke="url(#goldGradient)" stroke-width="2" opacity="0.7" />
<!-- ECHTE TROX-Karten als Fächer (Mittelpunkt im oberen Drittel) -->
${fan.svg}
<!-- TROX-Logo überdeckt den unteren Teil des Fächers -->
<text x="${cx}" y="${h * 0.64}" font-family="'Helvetica Neue',Arial Black,Arial,sans-serif" font-weight="900" font-size="${Math.round(w * 0.20)}" fill="url(#goldGradient)" text-anchor="middle" letter-spacing="${Math.round(w * 0.01)}">TROX</text>
<text x="${cx}" y="${h * 0.70}" font-family="'Helvetica Neue',Arial,sans-serif" font-size="${Math.round(w * 0.038)}" fill="#F3E5AB" text-anchor="middle" letter-spacing="4">TACTICAL CARD GAME</text>
<!-- Footer-Badge -->
<rect x="${cx - w * 0.36}" y="${h * 0.85}" width="${w * 0.72}" height="${h * 0.06}" rx="8" fill="none" stroke="url(#goldGradient)" stroke-width="1" opacity="0.8" />
<text x="${cx}" y="${h * 0.89}" font-family="'Helvetica Neue',Arial,sans-serif" font-size="${Math.round(w * 0.032)}" fill="#e5e7eb" text-anchor="middle" letter-spacing="1">2-6 Players | Ages 7+ | 80 Cards</text>`;
}
function backContent(w, h) {
const cx = w / 2;
return `
<rect width="${w}" height="${h}" fill="url(#bgGradient)" />
<rect x="${SAFE}" y="${SAFE}" width="${w - 2 * SAFE}" height="${h - 2 * SAFE}" rx="14" fill="none" stroke="url(#goldGradient)" stroke-width="2" opacity="0.7" />
<text x="${cx}" y="${SAFE + 80}" font-family="'Helvetica Neue',Arial,sans-serif" font-weight="bold" font-size="${Math.round(w * 0.06)}" fill="url(#goldGradient)" text-anchor="middle">HOW TO PLAY</text>
<g font-family="'Helvetica Neue',Arial,sans-serif" font-size="${Math.round(w * 0.032)}" fill="#e5e7eb">
<text x="${SAFE + 20}" y="${SAFE + 160}">Predict exactly how many tricks</text>
<text x="${SAFE + 20}" y="${SAFE + 205}">you will win each round.</text>
<text x="${SAFE + 20}" y="${SAFE + 275}">Hit your bid exactly -> +10 bonus.</text>
<text x="${SAFE + 20}" y="${SAFE + 320}">Miss it -> no bonus at all.</text>
<text x="${SAFE + 20}" y="${SAFE + 390}">Red (Crown) is the permanent</text>
<text x="${SAFE + 20}" y="${SAFE + 435}">trump and beats every suit.</text>
<text x="${SAFE + 20}" y="${SAFE + 505}">10 rounds. Cards rise then fall:</text>
<text x="${SAFE + 20}" y="${SAFE + 550}">1-2-3-4-5-5-4-3-2-1</text>
</g>
<text x="${cx}" y="${h - SAFE - 120}" font-family="'Helvetica Neue',Arial,sans-serif" font-style="italic" font-size="${Math.round(w * 0.034)}" fill="#F3E5AB" text-anchor="middle">"Bid exactly. Win tricks.</text>
<text x="${cx}" y="${h - SAFE - 80}" font-family="'Helvetica Neue',Arial,sans-serif" font-style="italic" font-size="${Math.round(w * 0.034)}" fill="#F3E5AB" text-anchor="middle">Rule the table."</text>`;
}
function spineContent(w, h) {
return `
<rect width="${w}" height="${h}" fill="url(#bgGradient)" />
<text x="${w / 2}" y="${h / 2}" font-family="'Helvetica Neue',Arial Black,sans-serif" font-weight="900" font-size="${Math.round(w * 0.5)}" fill="url(#goldGradient)" text-anchor="middle" dominant-baseline="middle" letter-spacing="${Math.round(w * 0.12)}" transform="rotate(90 ${w / 2} ${h / 2})">TROX</text>`;
}
function buildFront() {
const w = PANEL_W + 2 * BLEED;
const h = PANEL_H + 2 * BLEED;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
${DEFS}
${frontContent(w, h)}
</svg>`;
}
function buildWrap() {
const bandW = PANEL_W + SPINE + PANEL_W + SPINE;
const bandH = PANEL_H;
const totalW = bandW + 2 * BLEED;
const totalH = bandH + 2 * FLAP + 2 * BLEED;
const ox = BLEED, oy = BLEED + FLAP;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${totalW} ${totalH}" width="${totalW}" height="${totalH}">
${DEFS}
<rect width="${totalW}" height="${totalH}" fill="#0b0f17" />
<g transform="translate(${ox}, ${BLEED})">
<rect width="${PANEL_W}" height="${FLAP}" fill="url(#bgGradient)" opacity="0.85" />
<path d="M ${PANEL_W * 0.12} 0 Q ${PANEL_W / 2} ${-FLAP * 0.5} ${PANEL_W * 0.88} 0" fill="url(#bgGradient)" opacity="0.6"/>
</g>
<g transform="translate(${ox}, ${oy})">
<svg x="0" y="0" width="${PANEL_W}" height="${PANEL_H}" viewBox="0 0 ${PANEL_W} ${PANEL_H}">${DEFS}${frontContent(PANEL_W, PANEL_H)}</svg>
<g transform="translate(${PANEL_W}, 0)">
<svg x="0" y="0" width="${SPINE}" height="${PANEL_H}" viewBox="0 0 ${SPINE} ${PANEL_H}">${DEFS}${spineContent(SPINE, PANEL_H)}</svg>
</g>
<g transform="translate(${PANEL_W + SPINE}, 0)">
<svg x="0" y="0" width="${PANEL_W}" height="${PANEL_H}" viewBox="0 0 ${PANEL_W} ${PANEL_H}">${DEFS}${backContent(PANEL_W, PANEL_H)}</svg>
</g>
<g transform="translate(${PANEL_W + SPINE + PANEL_W}, 0)">
<svg x="0" y="0" width="${SPINE}" height="${PANEL_H}" viewBox="0 0 ${SPINE} ${PANEL_H}">${DEFS}${spineContent(SPINE, PANEL_H)}</svg>
</g>
</g>
<g transform="translate(${ox}, ${oy + PANEL_H})">
<rect width="${PANEL_W}" height="${FLAP}" fill="url(#bgGradient)" opacity="0.85" />
</g>
<g stroke="#6b7280" stroke-width="1" stroke-dasharray="6 6" opacity="0.5" fill="none">
<line x1="${ox}" y1="${oy}" x2="${ox + bandW}" y2="${oy}" />
<line x1="${ox}" y1="${oy + PANEL_H}" x2="${ox + bandW}" y2="${oy + PANEL_H}" />
<line x1="${ox + PANEL_W}" y1="${oy}" x2="${ox + PANEL_W}" y2="${oy + PANEL_H}" />
<line x1="${ox + PANEL_W + SPINE}" y1="${oy}" x2="${ox + PANEL_W + SPINE}" y2="${oy + PANEL_H}" />
<line x1="${ox + PANEL_W + SPINE + PANEL_W}" y1="${oy}" x2="${ox + PANEL_W + SPINE + PANEL_W}" y2="${oy + PANEL_H}" />
</g>
<text x="${totalW / 2}" y="${totalH - 12}" font-family="Arial" font-size="20" fill="#6b7280" text-anchor="middle">Faltlinien (gestrichelt) vor dem Upload entfernen - Maße an TGC-Tuckbox-Template angleichen</text>
</svg>`;
}
const outputDir = path.join(__dirname, 'box_export');
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
console.log('TROX Box wird generiert ...');
if (MODE === 'front' || MODE === 'both') {
fs.writeFileSync(path.join(outputDir, 'trox_box_front.svg'), buildFront());
console.log(` Front-Panel -> box_export/trox_box_front.svg (${PANEL_W + 2 * BLEED}x${PANEL_H + 2 * BLEED})`);
}
if (MODE === 'wrap' || MODE === 'both') {
fs.writeFileSync(path.join(outputDir, 'trox_box_wrap.svg'), buildWrap());
console.log(` Box-Netz -> box_export/trox_box_wrap.svg`);
}
console.log('Fertig.');

View File

@@ -0,0 +1,41 @@
const fs = require('fs');
const path = require('path');
const { buildCardSVG, SUITS } = require('./trox_card');
/* ============================================================
TROX Kartenvorderseiten Generator
node generate_cards.js tgc | original | both
Nutzt das gemeinsame Modul trox_card.js (eine Quelle fürs Design).
Codes: R=Red(Trumpf), Y=Yellow, G=Green, S=Black.
============================================================ */
const MODE = (process.argv[2] || 'both').toLowerCase();
const SPECS = {
tgc: { width: 825, height: 1125, safe: 75, cornerRadius: 0, dir: 'karten_export_tgc' },
original: { width: 694, height: 1069, safe: 35, cornerRadius: 35, dir: 'karten_export_original' },
};
function generate(variant) {
const spec = SPECS[variant];
const outputDir = path.join(__dirname, spec.dir);
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
let count = 0;
for (const colorName of Object.keys(SUITS)) {
for (let num = 1; num <= 20; num++) {
const svg = buildCardSVG(colorName, num, {
width: spec.width, height: spec.height, safe: spec.safe, cornerRadius: spec.cornerRadius,
});
const fileName = `${colorName}_${String(num).padStart(2, '0')}.svg`;
fs.writeFileSync(path.join(outputDir, fileName), svg);
count++;
}
}
console.log(` [${variant}] ${count} Karten -> ${spec.dir}/ (${spec.width}x${spec.height})`);
}
console.log('TROX Karten werden generiert ...');
if (MODE === 'tgc' || MODE === 'both') generate('tgc');
if (MODE === 'original' || MODE === 'both') generate('original');
console.log('Fertig.');

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.');

508
card-game/make_rules.js Normal file
View File

@@ -0,0 +1,508 @@
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
const sharp = require('sharp');
const QRCode = require('qrcode');
const { SUITS, buildCardSVG } = require('./trox_card');
/* ============================================================
TROX Erweiterte offizielle Spielanleitung (EN)
Node.js / pdfkit. Erzeugt TROX_Rules_Extended_EN.pdf
node make_rules.js
Codes: R=Red(Trump), Y=Yellow, G=Green, B=Black.
Beispiele zeigen echte Mini-Karten (aus trox_card.js).
Am Ende QR-Code zur Scorepad-Webseite.
============================================================ */
// ====== HIER ANPASSEN: Ziel-URL des QR-Codes (Webseite noch zu erstellen) ======
const SCOREPAD_URL = 'https://trox.example.com/scorepad';
// ===============================================================================
// ---- Palette ----
const C = {
gold: '#AA7C11',
goldLt: '#D4AF37',
dark: '#111827',
red: '#E53935',
yellow: '#C9A300', // dunkler für Lesbarkeit auf Weiß
green: '#43A047',
black: '#212121',
greyBg: '#F3F4F6',
greyLn: '#D1D5DB',
grey: '#6B7280',
bodyTx: '#212121',
white: '#FFFFFF',
};
// Mapping: Kartenkürzel (R/Y/G/B) -> Farbname im Modul
const COLOR_BY_CODE = { R: 'Rot', Y: 'Gelb', G: 'Gruen', B: 'Schwarz' };
// Cache für gerenderte Mini-Karten-PNG-Buffer, key = "R11"
const CHIP_CACHE = {};
async function renderChip(code) {
if (CHIP_CACHE[code]) return CHIP_CACHE[code];
const m = code.match(/^([RYGB])(\d+)$/);
if (!m) return null;
const colorName = COLOR_BY_CODE[m[1]];
const num = parseInt(m[2], 10);
// ECHTE Karte aus dem gemeinsamen Modul, klein gerendert -> identische Optik
const svg = buildCardSVG(colorName, num, { width: 694, height: 1069, safe: 35, cornerRadius: 35 });
const buf = await sharp(Buffer.from(svg), { density: 150 })
.resize(74, 114, { fit: 'fill' })
.flatten({ background: '#ffffff' })
// feiner grauer Rahmen, damit die Karte sich vom weißen Hintergrund abhebt
.extend({ top: 2, bottom: 2, left: 2, right: 2, background: '#B8Bec9' })
.png().toBuffer();
CHIP_CACHE[code] = buf;
return buf;
}
async function preRenderChips(codes) {
for (const c of codes) await renderChip(c);
}
// Sammelt automatisch alle Chip-Codes aus den Tabellen-Datenstrukturen,
// damit keine Karte vergessen werden kann (Bugfix gegen fehlende Chips).
function collectChipCodes(...tables) {
const set = new Set();
const walk = (cell) => {
if (cell && typeof cell === 'object' && Array.isArray(cell.chips)) {
cell.chips.forEach(c => set.add(c));
}
};
for (const tbl of tables) for (const row of tbl) for (const cell of row) walk(cell);
return [...set];
}
let QR_BUFFER = null;
async function renderQR(url) {
QR_BUFFER = await QRCode.toBuffer(url, {
type: 'png', margin: 1, width: 300,
color: { dark: '#111827', light: '#ffffff' },
errorCorrectionLevel: 'M',
});
}
const PAGE = { size: 'LETTER', margins: { top: 43, bottom: 43, left: 54, right: 54 } };
const doc = new PDFDocument({ ...PAGE, info: { Title: 'TROX Official Game Rules', Author: 'Bay Area Affiliates' } });
const OUT = path.join(__dirname, 'TROX_Rules_Extended_EN.pdf');
doc.pipe(fs.createWriteStream(OUT));
const PW = doc.page.width;
const ML = PAGE.margins.left;
const MR = PAGE.margins.right;
const CONTENT_W = PW - ML - MR;
const BOTTOM = doc.page.height - PAGE.margins.bottom;
// ---- Helpers --------------------------------------------------
function ensureSpace(h) {
if (doc.y + h > BOTTOM) doc.addPage();
}
function h1(text) {
ensureSpace(40);
doc.moveDown(0.4);
doc.fillColor(C.gold).font('Helvetica-Bold').fontSize(15).text(text, ML, doc.y);
doc.moveDown(0.2);
}
function h2(text) {
ensureSpace(28);
doc.moveDown(0.3);
doc.fillColor(C.dark).font('Helvetica-Bold').fontSize(11.5).text(text, ML, doc.y);
doc.moveDown(0.15);
}
function hr(color = C.goldLt, thickness = 1.2) {
const y = doc.y + 2;
doc.save().moveTo(ML, y).lineTo(PW - MR, y).lineWidth(thickness).strokeColor(color).stroke().restore();
doc.y = y + 8;
}
// Rich body text with simple **bold** and *italic* markup
function body(text, opts = {}) {
const size = opts.size || 10;
const indent = opts.indent || 0;
const color = opts.color || C.bodyTx;
const x = ML + indent;
const w = CONTENT_W - indent;
ensureSpace(size + 6);
doc.fontSize(size);
const segments = parseMarkup(text);
// first segment sets position; continued segments flow
let first = true;
for (let i = 0; i < segments.length; i++) {
const s = segments[i];
const last = i === segments.length - 1;
doc.font(s.font).fillColor(s.color || color);
if (first) {
doc.text(s.t, x, doc.y, { width: w, continued: !last, align: opts.align || 'left', lineGap: 2 });
first = false;
} else {
doc.text(s.t, { continued: !last, lineGap: 2 });
}
}
doc.moveDown(opts.spaceAfter != null ? opts.spaceAfter : 0.45);
}
function bullet(label, text, opts = {}) {
const indent = 16;
const x = ML + indent;
const w = CONTENT_W - indent;
ensureSpace(16);
doc.fontSize(10);
// bullet dot
doc.font('Helvetica').fillColor(C.bodyTx).text('\u2022 ', ML + 4, doc.y, { continued: true });
if (label) doc.font('Helvetica-Bold').fillColor(C.bodyTx).text(label + ': ', { continued: true });
const segs = parseMarkup(text);
for (let i = 0; i < segs.length; i++) {
const s = segs[i];
doc.font(s.font).fillColor(s.color || C.bodyTx).text(s.t, { continued: i < segs.length - 1, lineGap: 2 });
}
doc.moveDown(0.3);
}
function note(text) {
// light gold box-ish note (just indented italic-ish)
const x = ML + 8;
ensureSpace(20);
doc.fontSize(9.5);
const segs = parseMarkup(text);
let first = true;
for (let i = 0; i < segs.length; i++) {
const s = segs[i];
doc.font(s.font).fillColor(s.color || C.dark);
if (first) { doc.text(s.t, x, doc.y, { width: CONTENT_W - 16, continued: i < segs.length - 1, lineGap: 2 }); first = false; }
else doc.text(s.t, { continued: i < segs.length - 1, lineGap: 2 });
}
doc.moveDown(0.45);
}
function small(text) {
doc.font('Helvetica-Oblique').fontSize(8.5).fillColor(C.grey)
.text(text, ML, doc.y, { width: CONTENT_W, lineGap: 1 });
doc.moveDown(0.35);
}
// Markup parser: **bold**, *italic*, ~color:#hex|text~
function parseMarkup(text) {
const out = [];
// tokenize on ** , * , and ~color|...~
const re = /(\*\*[^*]+\*\*|\*[^*]+\*|~[^~]+~)/g;
let last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push({ t: text.slice(last, m.index), font: 'Helvetica' });
const tok = m[0];
if (tok.startsWith('**')) out.push({ t: tok.slice(2, -2), font: 'Helvetica-Bold' });
else if (tok.startsWith('~')) {
const inner = tok.slice(1, -1);
const [col, ...rest] = inner.split('|');
out.push({ t: rest.join('|'), font: 'Helvetica-Bold', color: col });
} else out.push({ t: tok.slice(1, -1), font: 'Helvetica-Oblique' });
last = re.lastIndex;
}
if (last < text.length) out.push({ t: text.slice(last), font: 'Helvetica' });
return out.length ? out : [{ t: text, font: 'Helvetica' }];
}
// ---- Table renderer ------------------------------------------
// rows: array of arrays of cells. cell = string OR {t, color, font, align, mono}
// OR {chips:['R11','Y18'], align} to render mini-cards.
const CHIP_W = 24, CHIP_H = 37, CHIP_GAP = 5; // echtes Kartenformat (schmal/hoch)
function table(rows, colWidths, opts = {}) {
const headerBg = opts.headerBg || C.dark;
const headerTx = opts.headerTx || C.goldLt;
const zebra = opts.zebra != null ? opts.zebra : true;
const fontSize = opts.fontSize || 9.5;
const padX = 8, padY = 5;
const totalW = colWidths.reduce((a, b) => a + b, 0);
const x0 = ML + (opts.center ? (CONTENT_W - totalW) / 2 : 0);
function isChipCell(cell) { return typeof cell === 'object' && cell && Array.isArray(cell.chips); }
// estimate row heights
function cellH(cell, w) {
if (isChipCell(cell)) return CHIP_H + 2 * padY;
const t = (typeof cell === 'string') ? cell : (cell.t || '');
const f = (typeof cell === 'object' && cell.mono) ? 'Courier-Bold' : 'Helvetica';
doc.font(f).fontSize(fontSize);
return doc.heightOfString(t, { width: w - 2 * padX });
}
const rowHeights = rows.map((r) =>
Math.max(...r.map((c, ci) => cellH(c, colWidths[ci]))) + 2 * padY
);
const totalH = rowHeights.reduce((a, b) => a + b, 0);
ensureSpace(totalH + 4);
let y = doc.y;
for (let ri = 0; ri < rows.length; ri++) {
const rh = rowHeights[ri];
let x = x0;
const isHeader = ri === 0 && opts.header !== false;
if (isHeader) {
doc.save().rect(x0, y, totalW, rh).fill(headerBg).restore();
} else if (zebra && (ri % 2 === 0)) {
doc.save().rect(x0, y, totalW, rh).fill(C.greyBg).restore();
}
for (let ci = 0; ci < rows[ri].length; ci++) {
const cw = colWidths[ci];
const cell = rows[ri][ci];
if (isChipCell(cell)) {
// Mini-Karten zeichnen, links ausgerichtet (oder zentriert)
const n = cell.chips.length;
const totalChipW = n * CHIP_W + (n - 1) * CHIP_GAP;
let startX = x + padX;
if (cell.align === 'center') startX = x + (cw - totalChipW) / 2;
const cyChip = y + (rh - CHIP_H) / 2;
let cxChip = startX;
for (const code of cell.chips) {
const buf = CHIP_CACHE[code];
if (buf) doc.image(buf, cxChip, cyChip, { width: CHIP_W, height: CHIP_H });
cxChip += CHIP_W + CHIP_GAP;
}
} else {
const t = (typeof cell === 'string') ? cell : (cell.t || '');
let font = (typeof cell === 'object' && cell.font) ? cell.font
: (typeof cell === 'object' && cell.mono) ? 'Courier-Bold'
: isHeader ? 'Helvetica-Bold' : 'Helvetica';
let color = isHeader ? headerTx : (typeof cell === 'object' && cell.color) ? cell.color : C.bodyTx;
const align = (typeof cell === 'object' && cell.align) ? cell.align : 'left';
doc.font(font).fontSize(fontSize).fillColor(color)
.text(t, x + padX, y + padY, { width: cw - 2 * padX, align });
}
x += cw;
}
// grid lines
doc.save().rect(x0, y, totalW, rh).lineWidth(0.5).strokeColor(C.greyLn).stroke();
let cx = x0;
for (let ci = 0; ci < colWidths.length - 1; ci++) {
cx += colWidths[ci];
doc.moveTo(cx, y).lineTo(cx, y + rh).lineWidth(0.5).strokeColor(C.greyLn).stroke();
}
doc.restore();
y += rh;
}
doc.y = y + 6;
doc.x = ML;
}
// ============================================================
// CONTENT
// ============================================================
async function main() {
// QR vorab rendern; Chips werden bei den Beispielen automatisch gesammelt
await renderQR(SCOREPAD_URL);
// ---- Title ----
doc.moveDown(0.2);
doc.font('Helvetica-Bold').fontSize(34).fillColor(C.gold)
.text('TROX', ML, doc.y, { width: CONTENT_W, align: 'center' });
doc.font('Helvetica').fontSize(11).fillColor(C.black)
.text('O F F I C I A L G A M E R U L E S', { width: CONTENT_W, align: 'center', characterSpacing: 1 });
doc.moveDown(0.5);
hr();
// ---- 1. Overview ----
h1('1. Overview & Components');
body('TROX is a tactical trick-taking and bidding game for **2 to 6 players** (optimally balanced for **3 to 4 players**). The game rewards logical deduction, card counting, and precise risk assessment.');
body('The deck consists of **80 cards**, divided into four suits, each numbered from 1 to 20:');
table([
['Suit', 'Role', 'Symbol', 'Code'],
[{ t: 'Red', color: C.red, font: 'Helvetica-Bold' }, 'Permanent TRUMP suit', 'Crown', { t: 'R', font: 'Helvetica-Bold' }],
[{ t: 'Yellow', color: C.yellow, font: 'Helvetica-Bold' }, 'Standard suit', 'Sun', { t: 'Y', font: 'Helvetica-Bold' }],
[{ t: 'Green', color: C.green, font: 'Helvetica-Bold' }, 'Standard suit', 'Leaf', { t: 'G', font: 'Helvetica-Bold' }],
[{ t: 'Black', color: C.black, font: 'Helvetica-Bold' }, 'Standard suit', 'Gear', { t: 'B', font: 'Helvetica-Bold' }],
], [110, 240, 110, 70]);
note('**Red is always trump.** A Red card beats any card of the other three suits, regardless of number.');
// ---- 2. Pyramid ----
h1('2. Game Structure (The Pyramid)');
body('A full game consists of exactly **10 rounds**. The number of cards dealt to each player rises and then falls in a pyramid:');
table([
['Round', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'],
['Cards', '1', '2', '3', '4', '5', '5', '4', '3', '2', '1'],
], [80, ...Array(10).fill(48)], { header: false, zebra: false, fontSize: 9.5 });
// ---- 3. Bidding ----
h1('3. The Bidding Phase');
body('Before the first trick of a round is played, every player looks at their hand. In clockwise order, each player announces **exactly how many tricks they expect to win** this round. The scorekeeper writes this prediction (the **Bid**) into the small box at the top-left of the player\u2019s cell on the scorepad.');
note('A bid of **0** is allowed and is often very valuable: you score the +10 bonus simply by losing every trick on purpose.');
// ---- 4. Trick-Taking ----
h1('4. Trick-Taking Rules');
bullet('Following suit', 'The player who plays the first card of a trick sets the **lead suit**. Every other player **must follow suit** if they hold a card of that color.');
bullet('Sloughing & trumping', 'Only a player who holds **no** card of the lead suit may either discard a card of another standard suit (a slough) or play a Red card to trump the trick.');
bullet('The trump (Red)', 'Red beats all other suits. If several Red cards land in one trick, the **highest Red number** wins.');
bullet('Winning without trump', 'If no Red card is played, the trick goes to the **highest card of the lead suit**. Cards of other (non-lead, non-Red) colors are worth zero here, no matter how high.');
bullet('Leading the next trick', 'The winner of a trick leads the next one. The very first trick of a round is led by the player who bid first.');
// ---- 5. Scoring ----
h1('5. Scoring & Running Totals');
body('At the end of each round, players count the tricks they won:');
bullet('', 'Each won trick is worth **1 point**.');
bullet('', 'If the bid was **correct**, add a **+10 bonus**. If it was wrong (too high OR too low), the bonus is **0**.');
table([
['Bid', 'Tricks won', 'Result', 'Points (tricks + bonus)'],
['2', '2', 'exact bid -> bonus', { t: '2 (tricks) + 10 (exact bid) = 12', color: C.green, font: 'Helvetica-Bold' }],
['2', '3', 'bid over by one', { t: '3 (tricks) + 0 (missed bid) = 3', color: C.red, font: 'Helvetica-Bold' }],
['0', '0', 'exact bid -> bonus', { t: '0 (tricks) + 10 (exact bid) = 10', color: C.green, font: 'Helvetica-Bold' }],
['1', '0', 'bid under by one', { t: '0 (tricks) + 0 (missed bid) = 0', color: C.red, font: 'Helvetica-Bold' }],
], [44, 70, 120, 226], { fontSize: 9 });
body('**Running total:** keep a cumulative score for every player. After scoring a round, add the points to the player\u2019s previous total and write the **new total** in that round\u2019s cell. The bottom row (**Position**) shows the final standing. Highest score after Round 10 wins.');
// ---- 6. Worked Examples ----
doc.addPage();
h1('6. Worked Examples (3 Players)');
hr();
body('These are the **first three rounds of one game** (1, 2 and 3 cards), played out in full to show *why* each player plays each card. Players are **Anna**, **Ben** and **Carla**, seated in that clockwise order. Each card is shown as a small TROX card in its suit colour.');
// === Example A: 2-card round (vormals B) ===
const exA_hands = [
['Player', 'Hand'],
[{ t: 'Anna', font: 'Helvetica-Bold' }, { chips: ['R9', 'Y3'] }],
[{ t: 'Ben', font: 'Helvetica-Bold' }, { chips: ['Y17', 'Y8'] }],
[{ t: 'Carla', font: 'Helvetica-Bold' }, { chips: ['R15', 'B2'] }],
];
const exA_tricks = [
['Trick', 'Anna', 'Ben', 'Carla', 'Winner / why'],
[{ t: '1 (lead Yellow)' }, { chips: ['Y3'], align: 'center' }, { chips: ['Y17'], align: 'center' }, { chips: ['R15'], align: 'center' }, { t: 'Carla \u2013 trumps with Red 15. Ben bid 0, so he dumps his high Yellow 17 now while Carla is taking the trick anyway.', font: 'Helvetica' }],
[{ t: '2 (lead Black)' }, { chips: ['R9'], align: 'center' }, { chips: ['Y8'], align: 'center' }, { chips: ['B2'], align: 'center' }, { t: 'Anna \u2013 no Black, trumps Red 9; beats Carla\u2019s led Black 2. Ben sloughs his last card.', font: 'Helvetica' }],
];
const exA_result = [
['Player', 'Bid', 'Won', 'Points'],
['Anna', { t: '1', align: 'center' }, { t: '1', align: 'center' }, { t: '11 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Ben', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Carla', { t: '1', align: 'center' }, { t: '1', align: 'center' }, { t: '11 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
];
// === Example B: 3-card round (vormals A), Blätter rotiert ===
// Anna bot in Runde A zuerst (Nachteil) -> in Runde B ist Ben an der Reihe,
// bietet zuerst und führt den ersten Stich an. Die Blätter wandern mit
// (Annas -> Ben, Bens -> Carla, Carlas -> Anna), die Stichlogik bleibt gleich.
const exB_hands = [
['Player', 'Hand'],
[{ t: 'Ben', font: 'Helvetica-Bold' }, { chips: ['R11', 'Y18', 'B4'] }],
[{ t: 'Carla', font: 'Helvetica-Bold' }, { chips: ['G20', 'Y5', 'R2'] }],
[{ t: 'Anna', font: 'Helvetica-Bold' }, { chips: ['Y14', 'B19', 'G7'] }],
];
const exB_tricks = [
['Trick', 'Ben', 'Carla', 'Anna', 'Winner / why'],
[{ t: '1 (lead Yellow)' }, { chips: ['Y18'], align: 'center' }, { chips: ['Y5'], align: 'center' }, { chips: ['Y14'], align: 'center' }, { t: 'Ben \u2013 highest Yellow', font: 'Helvetica' }],
[{ t: '2 (lead Red)' }, { chips: ['R11'], align: 'center' }, { chips: ['R2'], align: 'center' }, { chips: ['B19'], align: 'center' }, { t: 'Ben \u2013 trump; Anna has no Red, sloughs Black 19', font: 'Helvetica' }],
[{ t: '3 (lead Black)' }, { chips: ['B4'], align: 'center' }, { chips: ['G20'], align: 'center' }, { chips: ['G7'], align: 'center' }, { t: 'Ben \u2013 only Black counts; Green 20 is worth 0', font: 'Helvetica' }],
];
const exB_result = [
['Player', 'Bid', 'Won', 'Points'],
['Ben', { t: '2', align: 'center' }, { t: '3', align: 'center' }, { t: '3 (missed bid \u2013 no bonus)', color: C.red }],
['Carla', { t: '1', align: 'center' }, { t: '0', align: 'center' }, '0 (missed bid)'],
['Anna', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
];
// === Example C: 1-card round (rotation continues) ===
// Ben gab -> Carla bietet zuerst & führt an. Bietreihenfolge: Carla, Anna, Ben.
const exC_hands = [
['Player', 'Hand'],
[{ t: 'Carla', font: 'Helvetica-Bold' }, { chips: ['G12'] }],
[{ t: 'Anna', font: 'Helvetica-Bold' }, { chips: ['R3'] }],
[{ t: 'Ben', font: 'Helvetica-Bold' }, { chips: ['Y19'] }],
];
const exC_tricks = [
['Trick', 'Carla', 'Anna', 'Ben', 'Winner / why'],
[{ t: '1 (lead Green)' }, { chips: ['G12'], align: 'center' }, { chips: ['R3'], align: 'center' }, { chips: ['Y19'], align: 'center' }, { t: 'Anna \u2013 her tiny Red 3 is a trump and beats everything. Ben\u2019s high Yellow 19 is worthless: it is neither trump nor the led suit.', font: 'Helvetica' }],
];
const exC_result = [
['Player', 'Bid', 'Won', 'Points'],
['Carla', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Anna', { t: '1', align: 'center' }, { t: '1', align: 'center' }, { t: '11 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Ben', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
];
// Alle Chips automatisch vorab rendern (kein Vergessen mehr möglich)
await preRenderChips(collectChipCodes(exA_hands, exA_tricks, exB_hands, exB_tricks, exC_hands, exC_tricks));
// --- Beispiel 1: 1-Karten-Runde (Daten in exC_*) ---
h2('Example A \u2014 Round 1: a 1-card round (the smallest trump still rules)');
body('This is the **first round** of the game. Ben dealt, so **Carla bids first** (the player to the dealer\u2019s left) and also leads the first \u2013 and only \u2013 trick. With a single card each, every player simply bids 0 or 1.');
table(exC_hands, [90, 330]);
body('**Bids (clockwise from Carla):** Carla bids **0**, Anna bids **1**, Ben bids **0**. Anna holds the only trump, so she confidently expects the trick.');
table(exC_tricks, [82, 56, 56, 56, 175], { fontSize: 9 });
table(exC_result, [90, 55, 55, 230], { fontSize: 9.5 });
note('**Lesson:** Trump always wins \u2013 size does not matter between suits. Anna\u2019s Red 3 beats Ben\u2019s Yellow 19 and Carla\u2019s Green 12, because only Red is trump and the others cannot follow it. Holding any Red card in a 1-card round is almost a guaranteed trick.');
// --- Beispiel 2: 2-Karten-Runde (Daten in exA_*) ---
doc.addPage();
h2('Example B \u2014 Round 2: a 2-card round (using trump to control tricks)');
body('On to **Round 2**, now with two cards each. The deal rotates one seat: Carla dealt this round, so **Anna bids first** and also leads the first trick. (These are the same three players continuing the same game.)');
table(exA_hands, [90, 330]);
body('**Bids (clockwise from Anna):** Anna **1**, Ben **0**, Carla **1**. Both Anna and Carla hold a trump, so each expects one trick.');
table(exA_tricks, [70, 50, 50, 50, 205], { fontSize: 9 });
table(exA_result, [90, 55, 55, 230], { fontSize: 9.5 });
note('**Lesson:** Ben was squeezed between two trumps and correctly read that he could not win a trick, so he bid 0 and threw away his dangerous high Yellow 17 first. Note that here the bids summed to 2 with exactly 2 tricks available \u2013 so it was possible (though not guaranteed) for everyone to hit. That tension is the heart of TROX.');
// --- Beispiel 3: 3-Karten-Runde (Daten in exB_*) ---
doc.addPage();
h2('Example C \u2014 Round 3: a 3-card round (the danger of overbidding)');
body('**Round 3**, three cards each. The deal rotates again: Anna dealt, so **Ben bids first** and leads the first trick. The first-bidder duty has now passed Carla -> Anna -> Ben across the three rounds.');
table(exB_hands, [90, 330]);
body('**Bids (clockwise from Ben):** Ben bids **2**, Carla bids **1**, Anna bids **0**.');
small('Anna\u2019s hand has no trump and only middling cards, so she sensibly aims to win nothing.');
table(exB_tricks, [70, 50, 50, 50, 205], { fontSize: 9 });
table(exB_result, [90, 55, 55, 230], { fontSize: 9.5 });
note('**Lesson:** Ben had the strongest hand and won all three tricks \u2013 but because he bid only 2, he scored just 3 points. Anna, with the weakest hand, scored the most by correctly predicting 0. In TROX, **accuracy beats raw strength.**');
// ---- 7. Strategy ----
doc.addPage();
h1('7. Strategy Tips');
hr();
bullet('Count the trumps', 'There are 20 Red cards. High Reds (16\u201320) are near-locks for a trick; low Reds may still be overtrumped. Track which Reds have been played.');
bullet('A high non-trump is fragile', 'Yellow 20 looks powerful, but it wins only if Yellow is led and nobody is void. Against players who are short in Yellow, it can be trumped away.');
bullet('Bidding 0 is a weapon', 'With no trump and only low cards, bid 0 and dump your cards safely. The +10 is identical to winning a 10-trick hand.');
bullet('Leading matters', 'The trick winner leads next \u2013 so winning early lets you steer suits. If you need to stop winning, lead a suit you think opponents can beat.');
bullet('Watch the bid total', 'Add up everyone\u2019s bids. If the total is below the number of tricks, someone *must* take an extra trick \u2013 figure out who is forced to.');
bullet('Late rounds shrink', 'When hands drop back to 1\u20132 cards, a single trump or void decides everything. Bid conservatively when you have no control.');
// ---- 8. Variant ----
h1('8. Optional Variant \u2014 "Exact Tension"');
body('For a sharper game, use the common trick-taking rule that **the bids may not add up to the exact number of tricks in the round.** The last player to bid (the dealer) is forced to bid a number that makes the total higher or lower than the available tricks. This guarantees that **at least one player will miss** every round, raising the pressure. This rule is optional \u2013 agree on it before the game starts.');
// ---- 9. Scorepad / QR ----
h1('9. Get Your Scorepad');
ensureSpace(150);
{
const boxY = doc.y + 4;
const qrSize = 96;
const boxH = qrSize + 28;
const boxX = ML;
const boxW = CONTENT_W;
// dezenter Rahmen
doc.save().roundedRect(boxX, boxY, boxW, boxH, 10).lineWidth(1).strokeColor(C.goldLt).stroke().restore();
// QR links
if (QR_BUFFER) doc.image(QR_BUFFER, boxX + 16, boxY + 14, { width: qrSize, height: qrSize });
// Text rechts daneben
const tx = boxX + 16 + qrSize + 18;
const tw = boxW - (16 + qrSize + 18) - 16;
let ty = boxY + 20;
doc.font('Helvetica-Bold').fontSize(12).fillColor(C.gold).text('Need a scorepad?', tx, ty, { width: tw });
ty = doc.y + 2;
doc.font('Helvetica').fontSize(9.5).fillColor(C.bodyTx)
.text('Scan the QR code to open the TROX scorepad page. Choose how many players you have, and the site prints the best-fitting PDF scorepad for your game.', tx, ty, { width: tw, lineGap: 2 });
doc.font('Helvetica-Oblique').fontSize(8).fillColor(C.grey)
.text(SCOREPAD_URL, tx, doc.y + 2, { width: tw });
doc.y = boxY + boxH + 8;
doc.x = ML;
}
// ---- Quick Reference ----
h2('Quick Reference');
table([
[{ t: 'Players', color: C.gold, font: 'Helvetica-Bold' }, '2\u20136 (best 3\u20134)'],
[{ t: 'Deck', color: C.gold, font: 'Helvetica-Bold' }, '80 cards \u2013 4 suits (R/Y/G/B), 1\u201320'],
[{ t: 'Trump', color: C.gold, font: 'Helvetica-Bold' }, 'Red (R) \u2013 always'],
[{ t: 'Rounds', color: C.gold, font: 'Helvetica-Bold' }, '10 \u2013 cards: 1\u00b72\u00b73\u00b74\u00b75\u00b75\u00b74\u00b73\u00b72\u00b71'],
[{ t: 'Per trick', color: C.gold, font: 'Helvetica-Bold' }, '+1 point'],
[{ t: 'Exact bid', color: C.gold, font: 'Helvetica-Bold' }, '+10 bonus (else 0)'],
[{ t: 'Win', color: C.gold, font: 'Helvetica-Bold' }, 'Highest total after Round 10'],
], [130, 300], { header: false, zebra: true });
doc.moveDown(0.4);
hr(C.greyLn, 0.8);
doc.font('Helvetica-Oblique').fontSize(9).fillColor(C.gold)
.text('TROX \u2013 Bid exactly. Win tricks. Rule the table.', ML, doc.y, { width: CONTENT_W, align: 'center' });
doc.end();
console.log('PDF erstellt: TROX_Rules_Extended_EN.pdf');
}
main().catch(err => { console.error(err); process.exit(1); });

65
card-game/svg_to_png.js Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
/* ============================================================
TROX SVG -> PNG Konverter für The Game Crafter
------------------------------------------------------------
TGC akzeptiert nur PNG/JPG bei 300 DPI in RGB (kein SVG, kein CMYK).
Rendert jede SVG exakt auf ihre width/height-Pixelgröße (= 300 DPI)
und gibt flaches RGB aus.
Voraussetzung (einmalig): npm install sharp
Verwendung:
node svg_to_png.js karten_export_tgc
node svg_to_png.js box_export
node svg_to_png.js scorepad_export
============================================================ */
const fs = require('fs');
const path = require('path');
let sharp;
try { sharp = require('sharp'); }
catch (e) {
console.error('Fehlendes Modul "sharp". Bitte zuerst ausführen:\n npm install sharp\n');
process.exit(1);
}
const srcDir = process.argv[2];
if (!srcDir || !fs.existsSync(srcDir)) {
console.error('Bitte gültigen Quellordner angeben, z.B.:\n node svg_to_png.js karten_export_tgc');
process.exit(1);
}
const outDir = `${srcDir.replace(/\/$/, '')}_png`;
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const files = fs.readdirSync(srcDir).filter(f => f.toLowerCase().endsWith('.svg'));
if (!files.length) { console.error('Keine SVG-Dateien gefunden in', srcDir); process.exit(1); }
function readSize(svg) {
const wm = svg.match(/<svg[^>]*\bwidth="(\d+(?:\.\d+)?)"/);
const hm = svg.match(/<svg[^>]*\bheight="(\d+(?:\.\d+)?)"/);
if (wm && hm) return { w: Math.round(+wm[1]), h: Math.round(+hm[1]) };
const vb = svg.match(/viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/);
if (vb) return { w: Math.round(+vb[1]), h: Math.round(+vb[2]) };
return null;
}
console.log(`Konvertiere ${files.length} SVG -> PNG (${srcDir} -> ${outDir}) ...`);
(async () => {
for (const f of files) {
const svg = fs.readFileSync(path.join(srcDir, f), 'utf8');
const size = readSize(svg);
const outPath = path.join(outDir, f.replace(/\.svg$/i, '.png'));
let img = sharp(Buffer.from(svg), { density: 300 });
if (size) img = img.resize(size.w, size.h, { fit: 'fill' });
await img
.flatten({ background: '#ffffff' })
.png({ compressionLevel: 9 })
.toFile(outPath);
}
console.log(`Fertig. PNGs liegen in ${outDir} (RGB, exakte 300-DPI-Pixelmaße).`);
})();

177
card-game/trox_card.js Normal file
View File

@@ -0,0 +1,177 @@
/* ============================================================
TROX Gemeinsames Karten-Zeichenmodul
------------------------------------------------------------
Eine einzige Quelle für das Kartendesign.
Wird von generate_cards.js (ganze Karten) UND
generate_box.js (skalierte Mini-Karten im Fächer) genutzt,
damit die Box-Karten exakt den echten Karten entsprechen.
Bezugssystem: Alle Zeichenfunktionen arbeiten in den
Koordinaten der ORIGINAL-Karte (694 x 1069). Für andere
Maße/Skalierungen wird außen herum transformiert.
============================================================ */
// Referenzmaße der Original-Karte (Designraster)
const REF_W = 694;
const REF_H = 1069;
// Farben + Codes (Y = Yellow, G = Green englische Logik)
const SUITS = {
Rot: { hex: '#E53935', code: 'R', name: 'Red' }, // Trumpf
Gelb: { hex: '#FDD835', code: 'Y', name: 'Yellow' },
Gruen: { hex: '#43A047', code: 'G', name: 'Green' },
Schwarz: { hex: '#212121', code: 'B', name: 'Black' },
};
// ------------------------------------------------------------
// Symbolzentrum (rotationssymmetrisches Paar) Koordinaten
// relativ zum Kartenmittelpunkt (0,0).
// ------------------------------------------------------------
function centerIcons(colorName, hex) {
if (colorName === 'Rot') {
const crown = `
<path d="M -20 12 L -24 -8 L -11 2 L 0 -15 L 11 2 L 24 -8 L 20 12 Z" fill="${hex}" />
<rect x="-20" y="14" width="40" height="4" fill="${hex}" rx="1" />`;
return `
<g transform="translate(0, -22)">${crown}</g>
<g transform="rotate(180) translate(0, -22)">${crown}</g>`;
}
if (colorName === 'Gruen') {
const leaf = `
<path d="M 0 12 Q -16 2 -14 -12 Q -9 -22 0 -25 Q 9 -22 14 -12 Q 16 2 0 12" fill="${hex}" />
<path d="M 0 12 Q -5 16 -10 20" fill="none" stroke="${hex}" stroke-width="3" stroke-linecap="round" />`;
return `
<g transform="translate(0, -22)">${leaf}</g>
<g transform="rotate(180) translate(0, -22)">${leaf}</g>`;
}
if (colorName === 'Gelb') {
const sunRay = `<path d="M 0 -12 L 2.5 -19 L 0 -22 L -2.5 -19 Z" fill="${hex}" />`;
const singleSun = `
<circle cx="0" cy="0" r="8" fill="${hex}" />
${sunRay}
<g transform="rotate(45)">${sunRay}</g>
<g transform="rotate(90)">${sunRay}</g>
<g transform="rotate(135)">${sunRay}</g>
<g transform="rotate(180)">${sunRay}</g>
<g transform="rotate(225)">${sunRay}</g>
<g transform="rotate(270)">${sunRay}</g>
<g transform="rotate(315)">${sunRay}</g>`;
return `
<g transform="translate(0, -22)">${singleSun}</g>
<g transform="rotate(180) translate(0, -22)">${singleSun}</g>`;
}
// Schwarz Zahnrad-Paar
const singleGear = `
<rect x="-2.5" y="-14" width="5" height="28" fill="${hex}" rx="1"/>
<rect x="-2.5" y="-14" width="5" height="28" fill="${hex}" rx="1" transform="rotate(60)"/>
<rect x="-2.5" y="-14" width="5" height="28" fill="${hex}" rx="1" transform="rotate(120)"/>
<circle cx="0" cy="0" r="10" fill="${hex}"/>
<circle cx="0" cy="0" r="5" fill="#ffffff"/>
<circle cx="0" cy="0" r="1.5" fill="${hex}"/>`;
return `
<g transform="translate(0, -22)">${singleGear}</g>
<g transform="rotate(180) translate(0, -22)">${singleGear}</g>`;
}
// ------------------------------------------------------------
// cardArtwork: der gesamte Karteninhalt OHNE den weißen
// Kartenkörper als <g>, gezeichnet im Raster width x height.
// safe = Abstand für Eckelemente & Zierrand
// drawBorder = Zierrand zeichnen (ja bei echten Karten)
// ------------------------------------------------------------
function cardArtwork(colorName, num, { width = REF_W, height = REF_H, safe = 35, drawBorder = true, cornerRadius = 30 } = {}) {
const suit = SUITS[colorName];
const hex = suit.hex;
const cardCode = `TK-${suit.code}${num}`;
const cornerX = safe + 20;
const cornerNumY = safe + 55;
const cornerCodeY = cornerNumY + 22;
const centerGap = 70;
const border = drawBorder
? `<rect x="${safe}" y="${safe}" width="${width - 2 * safe}" height="${height - 2 * safe}" rx="${Math.max(0, cornerRadius - 5)}" fill="none" stroke="${hex}" stroke-width="2" opacity="0.4" />`
: '';
return `
${border}
<!-- Ecken oben -->
<text x="${cornerX}" y="${cornerNumY}" class="trox-cnum" fill="${hex}">${num}</text>
<text x="${cornerX}" y="${cornerCodeY}" class="trox-code">${cardCode}</text>
<text x="${width - cornerX}" y="${cornerNumY}" class="trox-cnum" fill="${hex}" text-anchor="end">${num}</text>
<text x="${width - cornerX}" y="${cornerCodeY}" class="trox-code" text-anchor="end">${cardCode}</text>
<!-- Ecken unten (180°) -->
<g transform="translate(${width}, ${height}) rotate(180)">
<text x="${cornerX}" y="${cornerNumY}" class="trox-cnum" fill="${hex}">${num}</text>
<text x="${cornerX}" y="${cornerCodeY}" class="trox-code">${cardCode}</text>
<text x="${width - cornerX}" y="${cornerNumY}" class="trox-cnum" fill="${hex}" text-anchor="end">${num}</text>
<text x="${width - cornerX}" y="${cornerCodeY}" class="trox-code" text-anchor="end">${cardCode}</text>
</g>
<!-- Große Mittelzahl OBEN -->
<text x="${width / 2}" y="${height / 2 - centerGap}" class="trox-bignum" fill="${hex}" text-anchor="middle">${num}</text>
<!-- Mittellinie + Symbolzentrum -->
<line x1="${safe + 5}" y1="${height / 2}" x2="${width / 2 - 50}" y2="${height / 2}" stroke="${hex}" stroke-width="4" stroke-linecap="round" />
<line x1="${width / 2 + 50}" y1="${height / 2}" x2="${width - safe - 5}" y2="${height / 2}" stroke="${hex}" stroke-width="4" stroke-linecap="round" />
<g transform="translate(${width / 2}, ${height / 2})">
${centerIcons(colorName, hex)}
</g>
<!-- Große Mittelzahl UNTEN (180°) -->
<g transform="translate(${width}, ${height}) rotate(180)">
<text x="${width / 2}" y="${height / 2 - centerGap}" class="trox-bignum" fill="${hex}" text-anchor="middle">${num}</text>
</g>`;
}
// CSS-Klassen, die cardArtwork voraussetzt (font-size SKALIERBAR über fontScale)
function cardStyles(hex, fontScale = 1) {
return `
.trox-cnum { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: bold; font-size: ${65 * fontScale}px; }
.trox-code { font-family: 'Helvetica Neue', Arial, sans-serif; font-weight: bold; font-size: ${14 * fontScale}px; fill: #888888; letter-spacing: 1px; }
.trox-bignum { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: bold; font-size: ${320 * fontScale}px; letter-spacing: -10px; }`;
}
// ------------------------------------------------------------
// buildCardSVG: vollständige, eigenständige Karten-SVG.
// ------------------------------------------------------------
function buildCardSVG(colorName, num, { width = REF_W, height = REF_H, safe = 35, cornerRadius = 35 } = {}) {
const suit = SUITS[colorName];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<defs>
<style>${cardStyles(suit.hex, 1)}</style>
</defs>
<rect width="${width}" height="${height}" ${cornerRadius ? `rx="${cornerRadius}"` : ''} fill="#ffffff" />
${cardArtwork(colorName, num, { width, height, safe, drawBorder: true, cornerRadius })}
</svg>`;
}
// ------------------------------------------------------------
// miniCardGroup: eine VOLLSTÄNDIGE Karte als skalierte <g>,
// zentriert auf (cx,cy), gedreht um rot Grad. Für den Box-Fächer.
// Zeichnet Schatten + weißen Körper + komplettes Artwork,
// alles im Referenzraster und dann sauber herunterskaliert.
// ------------------------------------------------------------
function miniCardGroup(colorName, num, cx, cy, targetWidth, rot = 0) {
const suit = SUITS[colorName];
const scale = targetWidth / REF_W; // ein einziger Skalenfaktor → 1:1 Optik
const w = REF_W, h = REF_H;
return `
<g transform="translate(${cx}, ${cy}) rotate(${rot}) scale(${scale}) translate(${-w / 2}, ${-h / 2})">
<style>${cardStyles(suit.hex, 1)}</style>
<!-- Schlagschatten -->
<rect x="14" y="20" width="${w}" height="${h}" rx="30" fill="#000000" opacity="0.28" />
<!-- weißer Kartenkörper -->
<rect x="0" y="0" width="${w}" height="${h}" rx="30" fill="#ffffff" stroke="${suit.hex}" stroke-width="3" />
<!-- exakt dasselbe Artwork wie auf der echten Karte -->
${cardArtwork(colorName, num, { width: w, height: h, safe: 35, drawBorder: true, cornerRadius: 30 })}
</g>`;
}
module.exports = {
REF_W, REF_H, SUITS,
centerIcons, cardArtwork, cardStyles, buildCardSVG, miniCardGroup,
};

81
generate_back.js Normal file
View File

@@ -0,0 +1,81 @@
const fs = require('fs');
const path = require('path');
// Exakte Maße in Pixeln für 300 DPI (2.3125" x 3.5625")
const width = 694;
const height = 1069;
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#111827" />
<stop offset="100%" stop-color="#1F2937" />
</linearGradient>
<linearGradient id="goldGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#D4AF37" />
<stop offset="50%" stop-color="#F3E5AB" />
<stop offset="100%" stop-color="#AA7C11" />
</linearGradient>
<g id="pattern-quarter">
<path d="M 0 0 L 150 150 L 0 300 Z" fill="none" stroke="#E53935" stroke-width="2" opacity="0.4"/>
<path d="M 0 0 L 120 120 L 0 240 Z" fill="none" stroke="#FDD835" stroke-width="2" opacity="0.4"/>
<path d="M 0 0 L 90 90 L 0 180 Z" fill="none" stroke="#43A047" stroke-width="2" opacity="0.4"/>
<path d="M 0 0 L 60 60 L 0 120 Z" fill="none" stroke="#2196F3" stroke-width="2" opacity="0.3"/>
</g>
</defs>
<rect width="${width}" height="${height}" rx="35" fill="url(#bgGradient)" />
<rect x="25" y="25" width="${width - 50}" height="${height - 50}" rx="20" fill="none" stroke="url(#goldGradient)" stroke-width="3" opacity="0.8" />
<rect x="35" y="35" width="${width - 70}" height="${height - 70}" rx="15" fill="none" stroke="#ffffff" stroke-width="1" opacity="0.1" />
<g transform="translate(35, 35)">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<g transform="translate(${width - 35}, 35) scale(-1, 1)">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<g transform="translate(35, ${height - 35}) scale(1, -1)">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<g transform="translate(${width - 35}, ${height - 35}) scale(-1, -1)">
<use href="#pattern-quarter" />
<use href="#pattern-quarter" transform="rotate(90) scale(1,-1)" />
</g>
<g transform="translate(${width / 2}, ${height / 2})">
<rect x="-120" y="-120" width="240" height="240" rx="20" fill="none" stroke="url(#goldGradient)" stroke-width="4" transform="rotate(45)" />
<rect x="-100" y="-100" width="200" height="200" rx="15" fill="none" stroke="#ffffff" stroke-width="1" opacity="0.2" transform="rotate(45)" />
<circle cx="0" cy="0" r="70" fill="none" stroke="#E53935" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="60" fill="none" stroke="#FDD835" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="50" fill="none" stroke="#43A047" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="40" fill="none" stroke="#212121" stroke-width="3" opacity="0.7" />
<circle cx="0" cy="0" r="30" fill="#111827" stroke="url(#goldGradient)" stroke-width="2" />
<g transform="translate(0, -250)">
<text x="0" y="0" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="900" font-size="36" fill="url(#goldGradient)" text-anchor="middle" letter-spacing="6">TROX</text>
</g>
<g transform="rotate(180) translate(0, -250)">
<text x="0" y="0" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="900" font-size="36" fill="url(#goldGradient)" text-anchor="middle" letter-spacing="6">TROX</text>
</g>
</g>
</svg>`;
// Zielordner überprüfen und anlegen, falls noch nicht vorhanden
const outputDir = path.join(__dirname, 'karten_export_v2');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
// SVG Datei speichern
const fileName = 'trox_card_back.svg';
fs.writeFileSync(path.join(outputDir, fileName), svgContent);
console.log('Erfolgreich! Die Rückseite wurde als "trox_card_back.svg" im Ordner "karten_export_v2" generiert.');

61
generate_box.js Normal file
View File

@@ -0,0 +1,61 @@
const fs = require('fs');
const path = require('path');
const width = 2000;
const height = 1800;
// Hilfsfunktion für eine kleine Karte auf der Box-Vorderseite
function drawMiniCard(x, y, rotation, color, num) {
return `
<g transform="translate(${x}, ${y}) rotate(${rotation})">
<rect x="-40" y="-60" width="80" height="120" rx="8" fill="white" stroke="${color}" stroke-width="2" />
<text x="0" y="10" font-family="Arial" font-weight="bold" font-size="40" fill="${color}" text-anchor="middle">${num}</text>
<text x="-32" y="-40" font-family="Arial" font-size="12" fill="${color}" text-anchor="start">${num}</text>
</g>`;
}
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#111827" />
<stop offset="100%" stop-color="#1F2937" />
</linearGradient>
<linearGradient id="goldGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#D4AF37" />
<stop offset="50%" stop-color="#F3E5AB" />
<stop offset="100%" stop-color="#AA7C11" />
</linearGradient>
</defs>
<rect width="${width}" height="${height}" fill="#f3f4f6" />
<g transform="translate(300, 200)">
<rect x="300" y="320" width="400" height="600" fill="url(#bgGradient)" stroke="#ef4444" stroke-width="2" />
<g transform="translate(500, 500)">
${drawMiniCard(-60, 20, -15, '#43A047', '7')}
${drawMiniCard(-20, 0, -5, '#212121', '12')}
${drawMiniCard(20, 10, 10, '#E53935', '11')}
${drawMiniCard(60, 30, 25, '#FDD835', '4')}
</g>
<text x="500" y="750" font-family="Arial Black" font-weight="900" font-size="70" fill="url(#goldGradient)" text-anchor="middle" letter-spacing="12">TROX</text>
<text x="500" y="820" font-family="Arial" font-size="18" fill="#F3E5AB" text-anchor="middle" letter-spacing="2">TACTICAL CARD GAME</text>
<rect x="300" y="920" width="400" height="600" fill="url(#bgGradient)" stroke="#ef4444" stroke-width="2" />
<text x="500" y="1050" font-family="Arial" font-weight="bold" font-size="24" fill="url(#goldGradient)" text-anchor="middle">HOW TO PLAY</text>
<text x="500" y="1100" font-family="Arial" font-size="16" fill="#ffffff" text-anchor="middle">Bid exactly. Win tricks. Rule the table.</text>
<rect x="350" y="1400" width="300" height="60" rx="10" fill="none" stroke="url(#goldGradient)" stroke-width="1" />
<text x="500" y="1435" font-family="Arial" font-size="14" fill="#9ca3af" text-anchor="middle">2-6 Players | Ages 7+ | 80 Cards</text>
<rect x="180" y="320" width="120" height="600" fill="url(#bgGradient)" stroke="#ef4444" stroke-width="2" />
<rect x="700" y="320" width="120" height="600" fill="url(#bgGradient)" stroke="#ef4444" stroke-width="2" />
<path d="M 300 100 Q 300 0 400 0 L 600 0 Q 700 0 700 100 L 670 200 L 330 200 Z" fill="url(#bgGradient)" />
</g>
</svg>`;
const outputDir = path.join(__dirname, 'karten_export_v2');
if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir); }
fs.writeFileSync(path.join(outputDir, 'trox_tuck_box_v3.svg'), svgContent);
console.log('Update abgeschlossen: Box-Design mit Karten-Illustration generiert.');

149
generate_cards.js Normal file
View File

@@ -0,0 +1,149 @@
const fs = require('fs');
const path = require('path');
// Exakte Maße in Pixeln für 300 DPI (2 5/16" x 3 9/16" -> 2.3125" x 3.5625")
const width = 694;
const height = 1069;
const colors = {
Rot: '#E53935', // Trumpf (R)
Gelb: '#FDD835', // Gelb (G)
Gruen: '#43A047', // Grün (GR)
Schwarz: '#212121' // Schwarz (S)
};
// Zielordner anlegen
const outputDir = path.join(__dirname, 'karten_export_v2');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
// Schleife durch Farben und Zahlen
for (const [colorName, hex] of Object.entries(colors)) {
const shortColor = colorName === 'Rot' ? 'R' : colorName === 'Gelb' ? 'G' : colorName === 'Gruen' ? 'GR' : 'S';
for (let num = 1; num <= 20; num++) {
// Offizieller Kartencode als Seriennummer anstelle des irritierenden Binärcodes
const cardCode = `TK-${shortColor}${num}`;
// Dynamische Generierung der vollkommen rotationssymmetrischen Symbole für die Mitte
let centerIcons = '';
if (colorName === 'Rot') {
// Symmetrisches Kronen-Paar für Trumpf
centerIcons = `
<g transform="translate(0, -22)">
<path d="M -20 12 L -24 -8 L -11 2 L 0 -15 L 11 2 L 24 -8 L 20 12 Z" fill="${hex}" />
<rect x="-20" y="14" width="40" height="4" fill="${hex}" rx="1" />
</g>
<g transform="rotate(180) translate(0, -22)">
<path d="M -20 12 L -24 -8 L -11 2 L 0 -15 L 11 2 L 24 -8 L 20 12 Z" fill="${hex}" />
<rect x="-20" y="14" width="40" height="4" fill="${hex}" rx="1" />
</g>
`;
} else if (colorName === 'Gruen') {
// Symmetrisches Pflanzen/Blatt-Paar für Grün
centerIcons = `
<g transform="translate(0, -22)">
<path d="M 0 12 Q -16 2 -14 -12 Q -9 -22 0 -25 Q 9 -22 14 -12 Q 16 2 0 12" fill="${hex}" />
<path d="M 0 12 Q -5 16 -10 20" fill="none" stroke="${hex}" stroke-width="3" stroke-linecap="round" />
</g>
<g transform="rotate(180) translate(0, -22)">
<path d="M 0 12 Q -16 2 -14 -12 Q -9 -22 0 -25 Q 9 -22 14 -12 Q 16 2 0 12" fill="${hex}" />
<path d="M 0 12 Q -5 16 -10 20" fill="none" stroke="${hex}" stroke-width="3" stroke-linecap="round" />
</g>
`;
} else if (colorName === 'Gelb') {
// Symmetrisches Sonnen/Sternen-Paar für Gelb
const sunRay = `<path d="M 0 -12 L 2.5 -19 L 0 -22 L -2.5 -19 Z" fill="${hex}" />`;
const singleSun = `
<circle cx="0" cy="0" r="8" fill="${hex}" />
${sunRay}
<g transform="rotate(45)">${sunRay}</g>
<g transform="rotate(90)">${sunRay}</g>
<g transform="rotate(135)">${sunRay}</g>
<g transform="rotate(180)">${sunRay}</g>
<g transform="rotate(225)">${sunRay}</g>
<g transform="rotate(270)">${sunRay}</g>
<g transform="rotate(315)">${sunRay}</g>
`;
centerIcons = `
<g transform="translate(0, -22)">${singleSun}</g>
<g transform="rotate(180) translate(0, -22)">${singleSun}</g>
`;
} else if (colorName === 'Schwarz') {
// Symmetrisches Admin/IT-Zahnrad-Paar für Schwarz
const singleGear = `
<rect x="-2.5" y="-14" width="5" height="28" fill="${hex}" rx="1"/>
<rect x="-2.5" y="-14" width="5" height="28" fill="${hex}" rx="1" transform="rotate(60)"/>
<rect x="-2.5" y="-14" width="5" height="28" fill="${hex}" rx="1" transform="rotate(120)"/>
<circle cx="0" cy="0" r="10" fill="${hex}"/>
<circle cx="0" cy="0" r="5" fill="#ffffff"/>
<circle cx="0" cy="0" r="1.5" fill="${hex}"/>
`;
centerIcons = `
<g transform="translate(0, -22)">${singleGear}</g>
<g transform="rotate(180) translate(0, -22)">${singleGear}</g>
`;
}
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<defs>
<style>
.text { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: bold; fill: ${hex}; }
.corner-num { font-size: 65px; }
.card-code { font-size: 14px; fill: #888888; font-family: 'Helvetica Neue', Arial, sans-serif; letter-spacing: 1px; }
.center-num { font-size: 320px; letter-spacing: -10px; }
</style>
</defs>
<!-- Karten-Hintergrund -->
<rect width="${width}" height="${height}" rx="35" fill="#ffffff" />
<!-- Innerer Rand für sauberen Beschnitt und edle Optik -->
<rect x="25" y="25" width="${width - 50}" height="${height - 50}" rx="20" fill="none" stroke="${hex}" stroke-width="2" opacity="0.4" />
<!-- Ecken Oben (Links und Rechts sichtbar beim Fächern) -->
<text x="55" y="90" class="text corner-num">${num}</text>
<text x="55" y="112" class="card-code">${cardCode}</text>
<text x="${width - 55}" y="90" class="text corner-num" text-anchor="end">${num}</text>
<text x="${width - 55}" y="112" class="card-code" text-anchor="end">${cardCode}</text>
<!-- Ecken Unten (um 180 Grad rotiert für perfekte Symmetrie) -->
<g transform="translate(${width}, ${height}) rotate(180)">
<text x="55" y="90" class="text corner-num">${num}</text>
<text x="55" y="112" class="card-code">${cardCode}</text>
<text x="${width - 55}" y="90" class="text corner-num" text-anchor="end">${num}</text>
<text x="${width - 55}" y="112" class="card-code" text-anchor="end">${cardCode}</text>
</g>
<!-- Große Zahl Mitte Oben -->
<text x="${width / 2}" y="465" class="text center-num" text-anchor="middle">${num}</text>
<!-- Mittellinie und die gespiegelten Farb-Symbole -->
<g>
<line x1="80" y1="${height / 2}" x2="${width / 2 - 50}" y2="${height / 2}" stroke="${hex}" stroke-width="4" stroke-linecap="round" />
<line x1="${width / 2 + 50}" y1="${height / 2}" x2="${width - 80}" y2="${height / 2}" stroke="${hex}" stroke-width="4" stroke-linecap="round" />
<!-- Das exakt ausbalancierte Symbolzentrum -->
<g transform="translate(${width / 2}, ${height / 2})">
${centerIcons}
</g>
</g>
<!-- Große Zahl Mitte Unten (um 180 Grad rotiert) -->
<g transform="translate(${width}, ${height}) rotate(180)">
<text x="${width / 2}" y="465" class="text center-num" text-anchor="middle">${num}</text>
</g>
</svg>`;
// SVG Datei speichern
const fileName = `${colorName}_${String(num).padStart(2, '0')}.svg`;
fs.writeFileSync(path.join(outputDir, fileName), svgContent);
}
}
console.log('Erfolgreich! 80 vollkommen rotationssymmetrische SVG-Karten wurden im Ordner "karten_export_v2" generiert.');

1001
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,10 @@
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"pdfkit": "^0.18.0",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"sharp": "^0.34.5",
"tslib": "^2.3.0",
"zone.js": "~0.14.10"
},