card game
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
74
card-game/README.md
Normal 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.
|
||||
BIN
card-game/TROX_Rules_Extended_EN.pdf
Normal file
BIN
card-game/TROX_Rules_Extended_EN.pdf
Normal file
Binary file not shown.
BIN
card-game/TROX_cards_TGC.zip
Normal file
BIN
card-game/TROX_cards_TGC.zip
Normal file
Binary file not shown.
BIN
card-game/TROX_cards_original.zip
Normal file
BIN
card-game/TROX_cards_original.zip
Normal file
Binary file not shown.
BIN
card-game/TROX_scorepad.zip
Normal file
BIN
card-game/TROX_scorepad.zip
Normal file
Binary file not shown.
95
card-game/generate_back.js
Normal file
95
card-game/generate_back.js
Normal 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
226
card-game/generate_box.js
Normal 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.');
|
||||
41
card-game/generate_cards.js
Normal file
41
card-game/generate_cards.js
Normal 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.');
|
||||
247
card-game/generate_scorepad.js
Normal file
247
card-game/generate_scorepad.js
Normal 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
508
card-game/make_rules.js
Normal 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
65
card-game/svg_to_png.js
Normal 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
177
card-game/trox_card.js
Normal 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
81
generate_back.js
Normal 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
61
generate_box.js
Normal 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
149
generate_cards.js
Normal 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
1001
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user