Decks World — spatial card browser
// init-decks.js — Decks World (wl-005) // Spatial card browser — decks as platonic solids in 3D space // Card count maps to solid shape. Click to enter a deck, see its cards.
(function () { 'use strict';
if (typeof THREE === 'undefined') { console.warn('init-decks: THREE.js not loaded'); return; }
// ── Constants ────────────────────────────────────────────────────────────
var CHIP_NAME = 'init-decks'; var BG_COLOR = 0x020202; var AMBIENT_COLOR = 0x3E3E3E; var DIR_COLOR = 0xffffff;
// Deck solid mapping: card_count → solid shape var DECK_SHAPES = [ { max: 5, geo: 'tetra', color: 0x707070, size: 0.6 }, { max: 12, geo: 'cube', color: 0x565656, size: 0.7 }, { max: 22, geo: 'octa', color: 0x8B8B8B, size: 0.8 }, { max: 54, geo: 'dodeca', color: 0xA7A7A7, size: 0.9 }, { max: Infinity, geo: 'icosa', color: 0x8B8B8B, size: 1.0 } ];
function shapeForCount(n) { for (var i = 0; i < DECK_SHAPES.length; i++) { if (n <= DECK_SHAPES[i].max) return DECK_SHAPES[i]; } return DECK_SHAPES[DECK_SHAPES.length - 1]; }
function makeGeometry(shapeKey, size) { switch (shapeKey) { case 'tetra': return new THREE.TetrahedronGeometry(size, 0); case 'cube': return new THREE.BoxGeometry(size * 1.2, size * 1.2, size * 1.2); case 'octa': return new THREE.OctahedronGeometry(size, 0); case 'dodeca': return new THREE.DodecahedronGeometry(size, 0); case 'icosa': return new THREE.IcosahedronGeometry(size, 0); default: return new THREE.IcosahedronGeometry(size, 0); } }
// ── Scene setup ──────────────────────────────────────────────────────────
// Mount into existing #world div (provided by init-shell) var mount = document.getElementById('world'); if (!mount) { mount = document.createElement('div'); mount.id = 'world'; document.body.appendChild(mount); }
var W = window.innerWidth; var H = window.innerHeight;
var renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(W, H); renderer.setClearColor(BG_COLOR); mount.appendChild(renderer.domElement);
var scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(BG_COLOR, 0.04);
var camera = new THREE.PerspectiveCamera(60, W / H, 0.1, 500); camera.position.set(0, 6, 22); camera.lookAt(0, 0, 0);
// Lights scene.add(new THREE.AmbientLight(AMBIENT_COLOR, 1.2)); var dirLight = new THREE.DirectionalLight(DIR_COLOR, 1.5); dirLight.position.set(5, 10, 8); scene.add(dirLight); var fillLight = new THREE.DirectionalLight(0x707070, 0.6); fillLight.position.set(-5, -3, -8); scene.add(fillLight);
// Starfield (function () { var starGeo = new THREE.BufferGeometry(); var starCount = 1200; var positions = new Float32Array(starCount * 3); for (var i = 0; i < starCount * 3; i++) { positions[i] = (Math.random() - 0.5) * 300; } starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); var starMat = new THREE.PointsMaterial({ color: 0xA7A7A7, size: 0.15, sizeAttenuation: true }); scene.add(new THREE.Points(starGeo, starMat)); })();
// ── State ────────────────────────────────────────────────────────────────
var deckObjects = []; // { deck, mesh, wireframe, pivot, basePos, rotSpeed } var selectedDeck = null; var hoveredObj = null; var isEntering = false;
// Overview camera target var camTarget = { x: 0, y: 6, z: 22 }; var camLook = { x: 0, y: 0, z: 0 };
// Manual orbit state var orbit = { active: false, startX: 0, startY: 0, phi: Math.PI / 6, theta: 0, radius: 22, targetPhi: Math.PI / 6, targetTheta: 0, targetRadius: 22 };
// ── HUD overlay ──────────────────────────────────────────────────────────
var hudEl = document.createElement('div'); hudEl.id = 'decks-hud'; hudEl.style.cssText = [ 'position:fixed', 'top:0', 'left:0', 'right:0', 'bottom:0', 'pointer-events:none', 'z-index:10', 'font-family:"hanken-grotesk", sans-serif' ].join(';'); document.body.appendChild(hudEl);
var titleEl = document.createElement('div'); titleEl.style.cssText = [ 'position:absolute', 'top:20px', 'left:50%', 'transform:translateX(-50%)', 'color:#707070', 'font-size:13px', 'letter-spacing:0.2em', 'text-transform:uppercase', 'opacity:0.7', 'text-shadow:0 0 12px #707070' ].join(';'); titleEl.textContent = '\u25c8 DECKS'; hudEl.appendChild(titleEl);
var infoEl = document.createElement('div'); infoEl.style.cssText = [ 'position:absolute', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)', 'color:#666', 'font-size:11px', 'letter-spacing:0.1em', 'text-align:center' ].join(';'); infoEl.textContent = 'loading decks\u2026'; hudEl.appendChild(infoEl);
var tooltipEl = document.createElement('div'); tooltipEl.style.cssText = [ 'position:absolute', 'background:rgba(0,0,0,0.85)', 'border:1px solid #707070', 'border-radius:4px', 'padding:6px 12px', 'color:#E8E8E8', 'font-size:12px', 'pointer-events:none', 'display:none', 'max-width:200px', 'text-align:center' ].join(';'); hudEl.appendChild(tooltipEl);
var backBtn = document.createElement('div'); backBtn.style.cssText = [ 'position:absolute', 'top:20px', 'left:24px', 'color:#707070', 'font-size:12px', 'cursor:pointer', 'pointer-events:auto', 'opacity:0', 'transition:opacity 0.3s', 'padding:6px 12px', 'border:1px solid #707070', 'border-radius:4px', 'background:rgba(0,0,0,0.6)', 'letter-spacing:0.1em' ].join(';'); backBtn.textContent = '\u2190 DECKS'; backBtn.addEventListener('click', exitDeck); hudEl.appendChild(backBtn);
var cardListEl = document.createElement('div'); cardListEl.style.cssText = [ 'position:absolute', 'right:24px', 'top:60px', 'bottom:60px', 'width:260px', 'overflow-y:auto', 'pointer-events:auto', 'background:rgba(0,0,0,0.7)', 'border-left:1px solid #333', 'padding:12px', 'display:none', 'font-size:11px', 'color:#ccc', 'scrollbar-width:thin' ].join(';'); hudEl.appendChild(cardListEl);
// ── Raycaster ────────────────────────────────────────────────────────────
var raycaster = new THREE.Raycaster(); var mouse = new THREE.Vector2();
// ── Layout: arrange N decks in concentric rings ──────────────────────────
function computeLayout(n) { var rings = []; var remaining = n; var radius = 0; var capacities = [1, 6, 10, 14]; for (var i = 0; i < capacities.length && remaining > 0; i++) { var count = Math.min(capacities[i], remaining); rings.push({ count: count, radius: radius }); remaining -= count; radius += 4.5; } if (remaining > 0) rings.push({ count: remaining, radius: radius });
var positions = []; for (var r = 0; r < rings.length; r++) { var ring = rings[r]; for (var j = 0; j < ring.count; j++) { var angle = (j / Math.max(1, ring.count)) * Math.PI * 2 - Math.PI / 2; positions.push({ x: Math.cos(angle) * ring.radius, y: (Math.random() - 0.5) * 1.5, z: Math.sin(angle) * ring.radius }); } } return positions; }
// ── Build deck meshes ─────────────────────────────────────────────────────
function buildDecks(decks) { var positions = computeLayout(decks.length);
for (var i = 0; i < decks.length; i++) { var deck = decks[i]; var shape = shapeForCount(deck.card_count || 0);
var geo = makeGeometry(shape.geo, shape.size); var mat = new THREE.MeshPhongMaterial({ color: shape.color, transparent: true, opacity: 0.85, shininess: 80, specular: 0x444444 }); var mesh = new THREE.Mesh(geo, mat);
var wMat = new THREE.MeshBasicMaterial({ color: shape.color, wireframe: true, transparent: true, opacity: 0.3 }); var wireframe = new THREE.Mesh(geo.clone(), wMat);
var pivot = new THREE.Group(); pivot.add(mesh); pivot.add(wireframe);
var pos = positions[i]; pivot.position.set(pos.x, pos.y, pos.z); scene.add(pivot);
deckObjects.push({ deck: deck, mesh: mesh, wireframe: wireframe, pivot: pivot, basePos: { x: pos.x, y: pos.y, z: pos.z }, rotSpeed: { x: (Math.random() - 0.5) * 0.008, y: (Math.random() - 0.5) * 0.012, z: (Math.random() - 0.5) * 0.006 }, hoverPhase: Math.random() * Math.PI * 2, originalColor: shape.color }); } }
// ── Card list panel ──────────────────────────────────────────────────────
function showCardList(deckId, deckTitle) { cardListEl.innerHTML = '<div style="color:#555;padding:4px 0">Loading\u2026</div>'; cardListEl.style.display = 'block';
fetch('/api/decks/' + deckId + '/cards') .then(function (r) { return r.json(); }) .then(function (data) { cardListEl.innerHTML = '';
var header = document.createElement('div'); header.style.cssText = 'color:#707070;margin-bottom:10px;font-size:12px;letter-spacing:0.1em;border-bottom:1px solid #333;padding-bottom:6px'; header.textContent = deckTitle.toUpperCase() + ' \u2014 ' + (data.count || 0) + ' CARDS'; cardListEl.appendChild(header);
var cards = data.cards || []; cards.forEach(function (card) { var el = document.createElement('div'); el.style.cssText = 'padding:4px 0;border-bottom:1px solid #111;cursor:default;color:#aaa;transition:padding-left 0.1s';
var num = card.card_number != null ? (card.card_number + '. ') : ''; var suit = card.suit ? (' \xb7 ' + card.suit) : ''; el.textContent = num + (card.title || card.name) + suit;
el.addEventListener('mouseenter', function () { el.style.color = '#fff'; el.style.paddingLeft = '6px'; }); el.addEventListener('mouseleave', function () { el.style.color = '#aaa'; el.style.paddingLeft = '0'; }); cardListEl.appendChild(el); }); }) .catch(function (err) { cardListEl.innerHTML = '<div style="color:#c44">Failed to load</div>'; console.error(CHIP_NAME + ': card fetch error', err); }); }
// ── Enter / exit deck ────────────────────────────────────────────────────
function enterDeck(obj) { if (isEntering || selectedDeck === obj) return; isEntering = true; selectedDeck = obj;
var pos = obj.pivot.position; camTarget = { x: pos.x, y: pos.y + 1, z: pos.z + 8 }; camLook = { x: pos.x, y: pos.y, z: pos.z };
deckObjects.forEach(function (o) { if (o !== obj) { o.mesh.material.opacity = 0.12; o.wireframe.material.opacity = 0.04; } });
backBtn.style.opacity = '1'; backBtn.style.pointerEvents = 'auto'; titleEl.textContent = '\u25c8 ' + (obj.deck.title || obj.deck.name).toUpperCase(); infoEl.textContent = obj.deck.card_count + ' cards'; showCardList(obj.deck.id, obj.deck.title || obj.deck.name);
setTimeout(function () { isEntering = false; }, 600); }
function exitDeck() { if (!selectedDeck) return; selectedDeck = null;
camTarget = { x: 0, y: 6, z: 22 }; camLook = { x: 0, y: 0, z: 0 };
deckObjects.forEach(function (o) { o.mesh.material.opacity = 0.85; o.wireframe.material.opacity = 0.3; });
backBtn.style.opacity = '0'; backBtn.style.pointerEvents = 'none'; titleEl.textContent = '\u25c8 DECKS'; infoEl.textContent = deckObjects.length + ' decks \xb7 click to enter \xb7 scroll to zoom \xb7 drag to orbit'; cardListEl.style.display = 'none'; cardListEl.innerHTML = ''; }
// ── Hover ────────────────────────────────────────────────────────────────
function checkHover(clientX, clientY) { mouse.x = (clientX / W) * 2 - 1; mouse.y = -(clientY / H) * 2 + 1; raycaster.setFromCamera(mouse, camera);
var meshes = deckObjects.map(function (o) { return o.mesh; }); var hits = raycaster.intersectObjects(meshes, false); var newHovered = hits.length > 0 ? deckObjects.find(function (o) { return o.mesh === hits[0].object; }) : null;
if (newHovered !== hoveredObj) { if (hoveredObj) { hoveredObj.mesh.material.emissive = new THREE.Color(0x000000); hoveredObj.mesh.material.emissiveIntensity = 0; } hoveredObj = newHovered; if (hoveredObj) { hoveredObj.mesh.material.emissive = new THREE.Color(hoveredObj.originalColor); hoveredObj.mesh.material.emissiveIntensity = 0.4; renderer.domElement.style.cursor = 'pointer'; tooltipEl.textContent = (hoveredObj.deck.title || hoveredObj.deck.name) + ' \xb7 ' + hoveredObj.deck.card_count + ' cards'; tooltipEl.style.display = 'block'; } else { renderer.domElement.style.cursor = 'default'; tooltipEl.style.display = 'none'; } }
if (hoveredObj) { tooltipEl.style.left = (clientX + 14) + 'px'; tooltipEl.style.top = (clientY - 10) + 'px'; } }
// ── Events ───────────────────────────────────────────────────────────────
renderer.domElement.addEventListener('mousemove', function (e) { checkHover(e.clientX, e.clientY); if (orbit.active) { orbit.targetTheta -= (e.clientX - orbit.startX) * 0.005; orbit.targetPhi = Math.max(0.1, Math.min(Math.PI * 0.48, orbit.targetPhi + (e.clientY - orbit.startY) * 0.005)); orbit.startX = e.clientX; orbit.startY = e.clientY; } });
renderer.domElement.addEventListener('mousedown', function (e) { if (e.button === 0) { orbit.active = true; orbit.startX = e.clientX; orbit.startY = e.clientY; } }); renderer.domElement.addEventListener('mouseup', function () { orbit.active = false; });
renderer.domElement.addEventListener('click', function (e) { if (!orbit.active) { mouse.x = (e.clientX / W) * 2 - 1; mouse.y = -(e.clientY / H) * 2 + 1; raycaster.setFromCamera(mouse, camera); var hits = raycaster.intersectObjects(deckObjects.map(function (o) { return o.mesh; }), false); if (hits.length > 0) { var obj = deckObjects.find(function (o) { return o.mesh === hits[0].object; }); if (obj) enterDeck(obj); } } });
renderer.domElement.addEventListener('wheel', function (e) { orbit.targetRadius = Math.max(5, Math.min(60, orbit.targetRadius + e.deltaY * 0.04)); e.preventDefault(); }, { passive: false });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') exitDeck(); });
window.addEventListener('resize', function () { W = window.innerWidth; H = window.innerHeight; camera.aspect = W / H; camera.updateProjectionMatrix(); renderer.setSize(W, H); });
// ── Animation ────────────────────────────────────────────────────────────
var clock = new THREE.Clock();
function animate() { requestAnimationFrame(animate); var t = clock.getElapsedTime();
if (!selectedDeck) { orbit.phi += (orbit.targetPhi - orbit.phi) * 0.08; orbit.theta += (orbit.targetTheta - orbit.theta) * 0.08; orbit.radius += (orbit.targetRadius - orbit.radius) * 0.08; camTarget.x = orbit.radius * Math.sin(orbit.phi) * Math.sin(orbit.theta); camTarget.y = orbit.radius * Math.cos(orbit.phi); camTarget.z = orbit.radius * Math.sin(orbit.phi) * Math.cos(orbit.theta); }
camera.position.x += (camTarget.x - camera.position.x) * 0.07; camera.position.y += (camTarget.y - camera.position.y) * 0.07; camera.position.z += (camTarget.z - camera.position.z) * 0.07; camera.lookAt(camLook.x, camLook.y, camLook.z);
deckObjects.forEach(function (obj) { obj.mesh.rotation.x += obj.rotSpeed.x; obj.mesh.rotation.y += obj.rotSpeed.y; obj.mesh.rotation.z += obj.rotSpeed.z; obj.wireframe.rotation.copy(obj.mesh.rotation); obj.pivot.position.y = obj.basePos.y + Math.sin(t * 0.8 + obj.hoverPhase) * 0.15; });
renderer.render(scene, camera); }
// ── Init ─────────────────────────────────────────────────────────────────
function init() { fetch('/api/decks') .then(function (r) { return r.json(); }) .then(function (data) { var decks = (data.decks || []).filter(function (d) { return d.card_count > 0; }); if (decks.length === 0) { titleEl.textContent = '\u25c8 DECKS \u2014 EMPTY'; infoEl.textContent = 'No decks in Portal'; return; } buildDecks(decks); infoEl.textContent = decks.length + ' decks \xb7 click to enter \xb7 scroll to zoom \xb7 drag to orbit'; animate(); }) .catch(function (err) { console.error(CHIP_NAME + ': /api/decks failed', err); infoEl.textContent = 'Failed to load decks'; }); }
window.WORLD_NAME = window.WORLD_NAME || 'decks';
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { setTimeout(init, 100); }
console.log('[' + CHIP_NAME + '] loaded'); })();