Files
troxler-skat/card-game/generate_box.js
2026-05-31 14:39:45 -05:00

227 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.');