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:
472
public/cards.html
Normal file
472
public/cards.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user