init-card-3d
// card-3d.js — a Card Thing as a rotatable 3D object. // // Decomposed out of runeboard_II so the monolith calls a clean API instead of // growing another ThreeJS scene inline. The card is a rounded-rect slab: the // front face is the pixel-for-pixel SVG card (served as PNG from // `/card/:id/png`), the back carries the deck's club mark, and the corners are // rounded to match the SVG exactly (radius from the `X-Card-Corner-Ratio` // header the PNG endpoint emits). // // API: window.Card3D.mount(container, cardId, opts?) -> { dispose() } // opts: { autoRotate: bool (default true), back: '#hex', edge: '#hex' } // // THREE r128 + OrbitControls are loaded on demand from the same CDNs the // runeboard uses, so this drops in anywhere without bundling.
(function () { var swatch = window.swatch || function (n) { return getComputedStyle(document.documentElement).getPropertyValue('--swatch-' + n).trim(); }; 'use strict';
var THREE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'; var ORBIT_URL = 'https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js';
// The card's aspect + fallback corner radius come from the template's // Card_Background rect (308.36 × 569.12, rx 22.12). var CARD_W = 1; var CARD_H = 569.12 / 308.36; // ≈ 1.846 var FALLBACK_RATIO = 22.12 / 308.36; // ≈ 0.0717
function loadScript(url, cb) { var s = document.createElement('script'); s.src = url; s.onload = cb; s.onerror = function () { cb(new Error('load ' + url)); }; document.head.appendChild(s); }
function ensureThree(cb) { if (window.THREE && THREE.OrbitControls) { cb(); return; } loadScript(THREE_URL, function () { if (!window.THREE) { cb(new Error('THREE failed')); return; } loadScript(ORBIT_URL, function () { cb(); }); }); }
// A rounded-rectangle THREE.Shape centred on the origin, `r` = corner radius. function roundedRect(w, h, r) { var x = -w / 2, y = -h / 2, s = new THREE.Shape(); s.moveTo(x + r, y); s.lineTo(x + w - r, y); s.quadraticCurveTo(x + w, y, x + w, y + r); s.lineTo(x + w, y + h - r); s.quadraticCurveTo(x + w, y + h, x + w - r, y + h); s.lineTo(x + r, y + h); s.quadraticCurveTo(x, y + h, x, y + h - r); s.lineTo(x, y + r); s.quadraticCurveTo(x, y, x + r, y); return s; }
// Normalise a ShapeGeometry's UVs to 0..1 across its bounding box, with // optional axis flips. The front decal uses no flips (a +z face viewed from // a +z camera maps right-way-up and un-mirrored). function normalizeUVs(geo, flipU, flipV) { geo.computeBoundingBox(); var bb = geo.boundingBox, pos = geo.attributes.position, uv = geo.attributes.uv; var dx = bb.max.x - bb.min.x, dy = bb.max.y - bb.min.y; for (var i = 0; i < uv.count; i++) { var u = (pos.getX(i) - bb.min.x) / dx; var v = (pos.getY(i) - bb.min.y) / dy; uv.setXY(i, flipU ? 1 - u : u, flipV ? 1 - v : v); } uv.needsUpdate = true; }
// A crisp text texture drawn on a high-DPI canvas. Canvas honours the // document's loaded fonts (New Rubrik via the Typekit kit), so the text // renders in the real face — sharp, DB-driven, no SVG raster. Returns // { tex, aspect } where aspect = width/height of the drawn block. function textTexture(lines, o) { o = o || {}; var dpr = Math.min(window.devicePixelRatio || 1, 3) * 4; // oversample for crispness var fontPx = o.fontPx || 40; var family = o.font || "'new-rubrik-edge','New Rubrik Edge',sans-serif"; var weight = o.weight || 700; var color = o.color || swatch('chrome-glyph-stroke'); var pad = fontPx * 0.3; var meas = document.createElement('canvas').getContext('2d'); meas.font = weight + ' ' + fontPx + 'px ' + family; var wmax = 0; lines.forEach(function (l) { wmax = Math.max(wmax, meas.measureText(l).width); }); var lineH = fontPx * 1.25; var W = Math.ceil(wmax + pad * 2), H = Math.ceil(lineH * lines.length + pad * 2); var c = document.createElement('canvas'); c.width = Math.ceil(W * dpr); c.height = Math.ceil(H * dpr); var g = c.getContext('2d'); g.scale(dpr, dpr); g.font = weight + ' ' + fontPx + 'px ' + family; g.fillStyle = color; g.textBaseline = 'top'; g.textAlign = o.align || 'left'; var x = o.align === 'center' ? W / 2 : pad; lines.forEach(function (l, i) { g.fillText(l, x, pad + i * lineH); }); var t = new THREE.CanvasTexture(c); t.anisotropy = 8; t.needsUpdate = true; return { tex: t, aspect: W / H }; }
// Wrap a string to ~`max` chars per line (naive word wrap). function wrapText(s, max) { var out = [], cur = ''; (s || '').split(/\s+/).forEach(function (w) { if (cur && (cur.length + 1 + w.length) > max) { out.push(cur); cur = ''; } cur = cur ? cur + ' ' + w : w; }); if (cur) out.push(cur); return out; }
// troika-three-text (SDF) — vector-crisp glyphs at any zoom, from a real // font file. Loaded from CDN as a UMD build that uses the global THREE, so // it shares our r128 instance. Falls back to canvas text if unavailable. var TROIKA_URL = 'https://unpkg.com/[email protected]/dist/troika-three-text.umd.js'; var FONT_URL = '/fonts/rubik.ttf'; function troikaText() { var m = window.troika_three_text || window.troikaThreeText; return m && m.Text; } function ensureTroika(cb) { if (troikaText()) { cb(troikaText()); return; } loadScript(TROIKA_URL, function () { cb(troikaText()); }); }
// Overlay live gem text (name = type · body = turquoise) from the DB — SDF // via troika when available, else high-DPI canvas. Positions are in // normalized card space (x∈[-0.5,0.5], y∈[-H/2,H/2]) matched to the frame. function addLiveText(group, cardId, z) { Promise.all([ fetch('/api/things/' + encodeURIComponent(cardId)).then(function (r) { return r.json(); }), new Promise(function (res) { ensureTroika(res); }) ]).then(function (arr) { var j = arr[0], Text = arr[1]; var t = j.data || j.thing || j; var gv = t.gem_values || {}; var kind = (gv.sapphire || '').toUpperCase(); var bodyGem = (typeof gv.turquoise === 'object' && gv.turquoise) || (typeof gv.pearl === 'object' && gv.pearl) || {}; var body = bodyGem.body || '';
if (Text) { // SDF path — crisp at any zoom. function sdf(str, size, x, y, anchor, align, maxW) { var t2 = new Text(); t2.text = str; t2.font = FONT_URL; t2.fontSize = size; t2.color = 0x272727; t2.anchorX = anchor; t2.anchorY = 'middle'; t2.textAlign = align || 'left'; if (maxW) t2.maxWidth = maxW; t2.position.set(x, y, z); t2.sync(); group.add(t2); } if (kind) sdf(kind, 0.11, 0, -0.70, 'center', 'center'); if (body) sdf(body, 0.045, -0.40, 0.14, 'left', 'left', 0.80); return; }
// Canvas fallback. function plane(block, height, x, y, anchorLeft) { if (!block.aspect) return; var w = height * block.aspect; var m = new THREE.Mesh(new THREE.PlaneGeometry(w, height), new THREE.MeshBasicMaterial({ map: block.tex, transparent: true })); m.position.set(anchorLeft ? x + w / 2 : x, y, z); group.add(m); } if (kind) plane(textTexture([kind], { fontPx: 60, weight: 800, align: 'center' }), 0.12, 0, -0.70, false); if (body) plane(textTexture(wrapText(body, 30), { fontPx: 34, weight: 400 }), 0.30, -0.40, 0.12, true); }).catch(function () {}); }
// Canvas texture for the card back: solid stock colour + a big centred club. function backTexture(color) { var c = document.createElement('canvas'); c.width = 512; c.height = Math.round(512 * CARD_H); var g = c.getContext('2d'); g.fillStyle = color; g.fillRect(0, 0, c.width, c.height); g.fillStyle = 'rgba(0,0,0,0.18)'; g.font = (c.width * 0.5) + 'px serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('♣', c.width / 2, c.height / 2); // ♣ var t = new THREE.CanvasTexture(c); t.anisotropy = 8; return t; }
function mount(container, cardId, opts) { opts = opts || {}; var autoRotate = opts.autoRotate !== false; var backColor = opts.back || swatch('ui-e9b84f'); var edgeColor = opts.edge || swatch('ui-e0a24a'); var disposed = false, raf = 0;
var renderer, cam, controls, group; var out = { dispose: function () { disposed = true; if (raf) cancelAnimationFrame(raf); if (renderer) { renderer.dispose(); if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement); } }, // Nudge the card's orientation (radians). Wired to the left wand arrows. rotate: function (dx, dy) { if (group) { group.rotation.y += dx; group.rotation.x += dy; } }, // Toggle the camera's auto-orbit (play/stop). Returns the new state. toggleSpin: function () { if (controls) { controls.autoRotate = !controls.autoRotate; return controls.autoRotate; } }, // Dolly the camera in/out — positive = further away. Right wand. zoom: function (delta) { if (!cam) return; var d = Math.max(1.8, Math.min(7, cam.position.length() + delta)); cam.position.setLength(d); } };
ensureThree(function (err) { if (err || disposed) { if (err) container.textContent = 'card-3d: ' + err.message; return; }
var w = container.clientWidth || 360, h = container.clientHeight || 480; var scene = new THREE.Scene(); cam = new THREE.PerspectiveCamera(35, w / h, 0.1, 100); cam.position.set(0, 0, 3.6); renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(w, h); container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.85)); var dl = new THREE.DirectionalLight(0xffffff, 0.5); dl.position.set(1, 1, 2); scene.add(dl);
controls = new THREE.OrbitControls(cam, renderer.domElement); controls.enablePan = false; controls.minDistance = 2; controls.maxDistance = 8; controls.autoRotate = autoRotate; controls.autoRotateSpeed = 1.6;
group = new THREE.Group(); scene.add(group);
// Fetch the PNG (as a blob) so we can read the corner-ratio header and // reuse the bytes as the texture — one request, header + image. var liveText = opts.text === 'live'; fetch('/card/' + encodeURIComponent(cardId) + '/png?w=1800' + (liveText ? '&text=off' : '')) .then(function (r) { var ratio = parseFloat(r.headers.get('X-Card-Corner-Ratio')) || FALLBACK_RATIO; return r.blob().then(function (b) { return { ratio: ratio, url: URL.createObjectURL(b) }; }); }) .then(function (d) { if (disposed) return; var r = d.ratio * CARD_W; var depth = 0.026; // ~75% of the original slab thickness
// Body: extruded rounded slab (thickness + rounded edge + back plane base). var body = new THREE.Mesh( new THREE.ExtrudeGeometry(roundedRect(CARD_W, CARD_H, r), { depth: depth, bevelEnabled: false }), new THREE.MeshStandardMaterial({ color: edgeColor, roughness: 0.8, metalness: 0 }) ); body.position.z = -depth / 2; group.add(body);
// Front face: the actual card PNG, crisp, on a rounded decal. new THREE.TextureLoader().load(d.url, function (tex) { if (disposed) return; tex.anisotropy = 8; var faceGeo = new THREE.ShapeGeometry(roundedRect(CARD_W, CARD_H, r)); // No flips: +z face viewed from +z camera maps the texture // right-way-up and un-mirrored with plain normalized UVs. normalizeUVs(faceGeo, false, false); var face = new THREE.Mesh(faceGeo, new THREE.MeshBasicMaterial({ map: tex, transparent: true })); face.position.z = depth / 2 + 0.001; group.add(face);
// Live gem text drawn natively (crisp, from the DB) over // the text-less frame — the SDF/canvas text path. if (liveText) addLiveText(group, cardId, depth / 2 + 0.002);
// Back face: club mark, facing -z. var backGeo = new THREE.ShapeGeometry(roundedRect(CARD_W, CARD_H, r)); normalizeUVs(backGeo, false, false); var back = new THREE.Mesh(backGeo, new THREE.MeshBasicMaterial({ map: backTexture(backColor) })); back.position.z = -depth / 2 - 0.001; back.rotation.y = Math.PI; group.add(back); }); }) .catch(function (e) { if (!disposed) container.setAttribute('data-card3d-error', e.message); });
function resize() { if (disposed) return; var ww = container.clientWidth || w, hh = container.clientHeight || h; cam.aspect = ww / hh; cam.updateProjectionMatrix(); renderer.setSize(ww, hh); } window.addEventListener('resize', resize);
(function loop() { if (disposed) return; raf = requestAnimationFrame(loop); controls.update(); renderer.render(scene, cam); })(); });
return out; }
window.Card3D = { mount: mount }; })();