Add printable weapon card sheets at /cards.html

Poker-card sized (63×88mm) cards matching the physical Deathwatch card format:
- Req cost badge top-left
- Title bar with weapon name
- Dark art box with category icon and class label
- Type line (Weapon – Melee, Weapon – Ranged, Equipment, etc.)
- Stats block (Dmg, Pen, Range, RoF, Clip, Rld, Special, Renown)
- Source abbreviation bottom-right

9 cards per A4 sheet, cut-ready.
Filter by category (Melee/Ranged/Grenade/Armour/Other).
Data loaded live from /api/weapons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 21:08:03 +01:00
parent f94095b22f
commit 373b7f5333

472
public/cards.html Normal file
View File

@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Deathwatch Card Sheets</title>
<style>
/* ═══ RESET ═══ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ═══ SCREEN CHROME ═══ */
body {
background: #2a2a2a;
font-family: 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
color: #111;
padding: 20px;
}
.controls {
background: #1a1a2e;
color: #c8a96e;
padding: 16px 24px;
margin-bottom: 20px;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.controls h1 { font-size: 18pt; letter-spacing: 2px; text-transform: uppercase; margin-right: auto; }
.btn {
padding: 8px 18px;
border: none;
border-radius: 4px;
font-size: 10pt;
cursor: pointer;
font-family: Arial, sans-serif;
font-weight: bold;
}
.btn-print { background: #c8a96e; color: #1a1a2e; }
.btn-print:hover { background: #e0c080; }
.filter-group { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-btn {
padding: 6px 12px;
border-radius: 3px;
font-size: 9pt;
cursor: pointer;
border: 2px solid transparent;
font-family: Arial, sans-serif;
}
.filter-btn.active { border-color: #c8a96e; }
.filter-btn[data-cat="Melee Weapon"] { background: #5a1a1a; color: #ffbbbb; }
.filter-btn[data-cat="Ranged Weapon"] { background: #1a2a4a; color: #bbccff; }
.filter-btn[data-cat="Grenade"] { background: #3a2a0a; color: #ffdd88; }
.filter-btn[data-cat="Armour"] { background: #1a3a1a; color: #aaffaa; }
.filter-btn[data-cat="Other"] { background: #2a1a3a; color: #ddaaff; }
.filter-btn[data-cat="all"] { background: #333; color: #ccc; }
#loading { color: #c8a96e; text-align: center; padding: 40px; font-size: 14pt; }
/* ═══ SHEET = one printable A4 page ═══ */
.sheet {
background: #fff;
width: 210mm;
min-height: 297mm;
margin: 0 auto 20px;
padding: 6mm;
display: grid;
grid-template-columns: repeat(3, 63mm);
grid-template-rows: repeat(3, 88mm);
gap: 3mm;
justify-content: center;
align-content: start;
}
/* ═══ THE CARD ═══ */
.card {
width: 63mm;
height: 88mm;
border: 2.5pt solid #111;
border-radius: 3mm;
background: #f0e8d8;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
font-size: 6pt;
}
/* req badge */
.req-badge {
position: absolute;
top: 1.5mm;
left: 1.5mm;
width: 6mm;
height: 6mm;
border-radius: 50%;
background: #1a1a2e;
border: 1pt solid #c8a96e;
color: #c8a96e;
display: flex;
align-items: center;
justify-content: center;
font-size: 6.5pt;
font-weight: bold;
font-family: Arial, sans-serif;
z-index: 10;
line-height: 1;
}
/* title bar */
.card-title-bar {
background: #e8dcc8;
border-bottom: 1.5pt solid #555;
padding: 1.5mm 2mm 1mm 8mm; /* left pad for req badge */
min-height: 8mm;
display: flex;
align-items: center;
flex-shrink: 0;
}
.card-title {
font-size: 7.5pt;
font-weight: bold;
color: #1a1a1a;
letter-spacing: 0.2pt;
line-height: 1.2;
}
/* art box */
.card-art {
flex: 0 0 32mm;
margin: 1.5mm 2mm 1mm;
border-radius: 4mm;
border: 1.5pt solid #333;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* oval vignette inside art box */
.card-art::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 85% 80% at 50% 50%, transparent 40%, rgba(0,0,0,0.8) 100%);
z-index: 2;
}
.card-art-inner {
position: relative;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1mm;
}
.card-art-icon {
font-size: 18pt;
filter: drop-shadow(0 0 3px rgba(200,169,110,0.8));
line-height: 1;
}
.card-art-class {
font-size: 5.5pt;
color: #c8a96e;
font-family: Arial, sans-serif;
letter-spacing: 1pt;
text-transform: uppercase;
}
/* type line */
.card-type-line {
text-align: center;
padding: 0.5mm 2mm;
font-size: 6pt;
font-style: italic;
color: #333;
border-top: 0.75pt solid #888;
border-bottom: 0.75pt solid #888;
background: #e0d4c0;
flex-shrink: 0;
}
/* stats block */
.card-stats {
padding: 1mm 2mm;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5mm;
overflow: hidden;
}
.stat-row {
display: flex;
flex-wrap: wrap;
gap: 1.5mm;
line-height: 1.5;
}
.stat-item {
white-space: nowrap;
font-size: 6pt;
}
.stat-label {
font-weight: bold;
color: #333;
font-family: Arial, sans-serif;
}
.stat-value {
color: #111;
}
.card-rules {
font-size: 5.5pt;
font-style: italic;
color: #444;
margin-top: 0.5mm;
line-height: 1.4;
border-top: 0.5pt solid #aaa;
padding-top: 0.5mm;
}
/* renown pip */
.renown-pip {
position: absolute;
bottom: 1mm;
right: 2mm;
font-size: 5pt;
font-family: Arial, sans-serif;
font-style: italic;
color: #666;
}
/* category colour accents */
.card[data-cat="Melee Weapon"] .card-title-bar { background: #e8d0c0; border-bottom-color: #7a2a1a; }
.card[data-cat="Melee Weapon"] .card-type-line { color: #7a2a1a; }
.card[data-cat="Melee Weapon"] .card-art { background: linear-gradient(160deg,#2a0a0a,#1a1a1a); }
.card[data-cat="Ranged Weapon"] .card-title-bar { background: #c8d4e8; border-bottom-color: #1a2a5a; }
.card[data-cat="Ranged Weapon"] .card-type-line { color: #1a2a5a; }
.card[data-cat="Ranged Weapon"] .card-art { background: linear-gradient(160deg,#0a0a2a,#1a1a1a); }
.card[data-cat="Grenade"] .card-title-bar { background: #e8e0c0; border-bottom-color: #5a3a00; }
.card[data-cat="Grenade"] .card-type-line { color: #5a3a00; }
.card[data-cat="Grenade"] .card-art { background: linear-gradient(160deg,#1a1000,#1a1a1a); }
.card[data-cat="Armour"] .card-title-bar { background: #c8e0c8; border-bottom-color: #1a4a1a; }
.card[data-cat="Armour"] .card-type-line { color: #1a4a1a; }
.card[data-cat="Armour"] .card-art { background: linear-gradient(160deg,#001a00,#1a1a1a); }
.card[data-cat="Other"] .card-title-bar { background: #dcc8e8; border-bottom-color: #3a1a5a; }
.card[data-cat="Other"] .card-type-line { color: #3a1a5a; }
.card[data-cat="Other"] .card-art { background: linear-gradient(160deg,#0a001a,#1a1a1a); }
/* ═══ PRINT ═══ */
@media print {
body { background: #fff; padding: 0; }
.controls { display: none; }
.sheet {
margin: 0;
page-break-after: always;
box-shadow: none;
padding: 5mm;
}
.sheet:last-child { page-break-after: avoid; }
.card { box-shadow: none; }
}
</style>
</head>
<body>
<div class="controls">
<h1>⚔ Deathwatch Card Sheets</h1>
<div class="filter-group">
<button class="filter-btn active" data-cat="all" onclick="setFilter('all')">All</button>
<button class="filter-btn" data-cat="Melee Weapon" onclick="setFilter('Melee Weapon')">Melee</button>
<button class="filter-btn" data-cat="Ranged Weapon" onclick="setFilter('Ranged Weapon')">Ranged</button>
<button class="filter-btn" data-cat="Grenade" onclick="setFilter('Grenade')">Grenades</button>
<button class="filter-btn" data-cat="Armour" onclick="setFilter('Armour')">Armour</button>
<button class="filter-btn" data-cat="Other" onclick="setFilter('Other')">Other</button>
</div>
<button class="btn btn-print" onclick="window.print()">🖨 Print</button>
</div>
<div id="loading">Loading cards from database…</div>
<div id="sheets"></div>
<script>
// ── Icons by category/class
const ICONS = {
'Melee Weapon': { default: '⚔', 'Chain': '⛓', 'Power': '⚡', 'Force': '🔮', 'Combat': '🗡' },
'Ranged Weapon': { default: '🔫', 'Heavy': '💥', 'Plasma': '🟢', 'Melta': '🔴', 'Flamer': '🔥', 'Sniper': '🎯', 'Bolt': '💠' },
'Grenade': { default: '💣' },
'Armour': { default: '🛡' },
'Other': { default: '⚙' },
}
function getIcon(cat, name) {
const map = ICONS[cat] || { default: '📦' }
for (const [key, icon] of Object.entries(map)) {
if (key !== 'default' && name.toLowerCase().includes(key.toLowerCase())) return icon
}
return map.default
}
function parseRanged(dmgStr) {
if (!dmgStr) return {}
const r = {}
for (const p of dmgStr.split(';').map(s => s.trim())) {
if (/^Range /i.test(p)) r.range = p.replace(/^Range /i,'')
else if (/^Pen /i.test(p)) r.pen = p.replace(/^Pen /i,'')
else if (/^Clip /i.test(p))r.clip = p.replace(/^Clip /i,'')
else if (/^Reload /i.test(p)) r.reload = p.replace(/^Reload /i,'')
else if (/^[S-]\/[0-9-]/.test(p)) r.rof = p
else if (/\dd\d/.test(p)) r.damage = p
else if (p) r.special = (r.special ? r.special+', ' : '') + p
}
return r
}
function parseMelee(s) {
// Some melee have everything in damage string with semicolons
let dmg = s.damage || '', pen = s.pen, special = s.special
if (dmg.includes(';')) {
const bits = dmg.split(';').map(x=>x.trim())
dmg = bits[0]
for (const b of bits.slice(1)) {
if (/^Pen \d/i.test(b)) pen = b.replace(/^Pen /i,'')
else if (b && b !== '-') special = (special ? special+', ' : '') + b
}
}
return { dmg, pen: pen||s.pen, special: special||s.special }
}
function esc(s) {
if (!s) return ''
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
}
function classShort(s) {
if (!s) return ''
return s.split(' - ').pop()
}
function buildCard(w) {
const s = w.stats || {}
const cat = w.category
const icon = getIcon(cat, w.name + ' ' + (s.class||''))
const req = s.req || ''
const renown = s.renown && s.renown !== '-' ? s.renown : ''
const cls = classShort(s.class) || cat.replace(' Weapon','')
// Build stat lines
let statsHtml = ''
let typeLine = cat
if (cat === 'Ranged Weapon' || (cat === 'Grenade' && s.damage && s.damage.includes(';'))) {
const p = parseRanged(s.damage)
typeLine = 'Weapon Ranged'
const rows = []
if (p.range) rows.push(`<span class="stat-item"><span class="stat-label">Range:</span> <span class="stat-value">${esc(p.range)}</span></span>`)
if (p.rof) rows.push(`<span class="stat-item"><span class="stat-label">ROF:</span> <span class="stat-value">${esc(p.rof)}</span></span>`)
if (p.damage)rows.push(`<span class="stat-item"><span class="stat-label">Dmg:</span> <span class="stat-value">${esc(p.damage)}</span></span>`)
if (p.pen) rows.push(`<span class="stat-item"><span class="stat-label">Pen:</span> <span class="stat-value">${esc(p.pen)}</span></span>`)
if (p.clip) rows.push(`<span class="stat-item"><span class="stat-label">Clip:</span> <span class="stat-value">${esc(p.clip)}</span></span>`)
if (p.reload)rows.push(`<span class="stat-item"><span class="stat-label">Rld:</span> <span class="stat-value">${esc(p.reload)}</span></span>`)
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
if (p.special) statsHtml += `<div class="stat-row"><span class="stat-item"><span class="stat-label">Special:</span> <span class="stat-value">${esc(p.special)}</span></span></div>`
} else if (cat === 'Melee Weapon') {
const m = parseMelee(s)
typeLine = 'Weapon Melee'
const rows = []
if (m.dmg) rows.push(`<span class="stat-item"><span class="stat-label">Dmg:</span> <span class="stat-value">${esc(m.dmg)}</span></span>`)
if (m.pen) rows.push(`<span class="stat-item"><span class="stat-label">Pen:</span> <span class="stat-value">${esc(m.pen)}</span></span>`)
if (s.wt && s.wt !== '-') rows.push(`<span class="stat-item"><span class="stat-label">Wt:</span> <span class="stat-value">${esc(s.wt)}</span></span>`)
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
if (m.special && m.special !== '') statsHtml += `<div class="stat-row"><span class="stat-item"><span class="stat-label">Special:</span> <span class="stat-value">${esc(m.special)}</span></span></div>`
} else if (cat === 'Grenade') {
typeLine = 'Weapon Ranged'
const bits = (s.damage||'').split(';').map(x=>x.trim())
const rows = []
for (const b of bits) {
if (/\dd\d/.test(b)) rows.push(`<span class="stat-item"><span class="stat-label">Dmg:</span> <span class="stat-value">${esc(b)}</span></span>`)
else if (/^Pen /i.test(b)) rows.push(`<span class="stat-item"><span class="stat-label">Pen:</span> <span class="stat-value">${esc(b.replace(/^Pen /i,''))}</span></span>`)
else if (b) rows.push(`<span class="stat-item"><span class="stat-value">${esc(b)}</span></span>`)
}
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
if (s.class) statsHtml += `<div class="stat-row"><span class="stat-item"><span class="stat-label">Class:</span> <span class="stat-value">${esc(classShort(s.class))}</span></span></div>`
} else if (cat === 'Armour') {
typeLine = 'Armour'
const rows = []
if (s.damage) rows.push(`<span class="stat-item"><span class="stat-value">${esc(s.damage)}</span></span>`)
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
} else {
typeLine = 'Equipment'
if (s.damage) statsHtml = `<div class="stat-row"><span class="stat-item"><span class="stat-value">${esc(s.damage)}</span></span></div>`
}
const srcShort = (w.source||'').replace('Deathwatch ','').replace('Core Rulebook','CR').replace('First Founding','FF').replace('Rites of Battle','RoB').replace('Mark of the Xenos','MoX').replace('Honour the Chapter','HtC').replace('Living Errata v1.1','Errata')
return `
<div class="card" data-cat="${esc(cat)}">
${req ? `<div class="req-badge">${req}</div>` : ''}
<div class="card-title-bar">
<div class="card-title">${esc(w.name)}</div>
</div>
<div class="card-art">
<div class="card-art-inner">
<div class="card-art-icon">${icon}</div>
<div class="card-art-class">${esc(cls)}</div>
</div>
</div>
<div class="card-type-line">${esc(typeLine)}</div>
<div class="card-stats">
${statsHtml}
${renown ? `<div class="stat-row" style="margin-top:auto"><span class="stat-item" style="font-style:italic;color:#666">Renown: ${esc(renown)}</span></div>` : ''}
</div>
<div class="renown-pip">${esc(srcShort)}</div>
</div>`
}
let allWeapons = []
let currentFilter = 'all'
function setFilter(cat) {
currentFilter = cat
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', b.dataset.cat === cat)
})
renderSheets()
}
function renderSheets() {
const weapons = currentFilter === 'all'
? allWeapons
: allWeapons.filter(w => w.category === currentFilter)
const sheetsEl = document.getElementById('sheets')
sheetsEl.innerHTML = ''
// 9 cards per A4 sheet
for (let i = 0; i < weapons.length; i += 9) {
const chunk = weapons.slice(i, i + 9)
const sheetDiv = document.createElement('div')
sheetDiv.className = 'sheet'
sheetDiv.innerHTML = chunk.map(buildCard).join('')
sheetsEl.appendChild(sheetDiv)
}
}
async function loadWeapons() {
try {
const res = await fetch('/api/weapons')
allWeapons = await res.json()
// Sort: melee first, then ranged, then grenades, armour, other
const order = { 'Melee Weapon': 0, 'Ranged Weapon': 1, 'Grenade': 2, 'Armour': 3, 'Other': 4 }
allWeapons.sort((a, b) => {
const od = (order[a.category]||9) - (order[b.category]||9)
return od !== 0 ? od : a.name.localeCompare(b.name)
})
document.getElementById('loading').style.display = 'none'
renderSheets()
} catch(e) {
document.getElementById('loading').textContent = 'Failed to load — is the server running?'
}
}
loadWeapons()
</script>
</body>
</html>