Dice World — Card system, tray, actions, smash

// dc-cards.js — Dice World: Unified Card System, tray, actions, smash/pop // ca-013 | load_order 110 | world: dice

(function() { if (!window.DC) { console.warn('dc-cards: DC not initialized'); return; } var DC = window.DC;

// Local aliases for immutable scene objects (set by dc-scene before we run) var scene = DC.scene, camera = DC.camera; var dice = DC.dice; // alias: never reassigned, only mutated

// Local aliases for constants var GEM_NAMES = DC.GEM_NAMES, GEM_COLORS = DC.GEM_COLORS, GEM_LABELS = DC.GEM_LABELS;

// ── Local card constants ── var CARD_CSS = 'position:fixed;display:none;z-index:300;' + 'background:rgba(18,18,18,0.95);color:#ccd;font:13px "hanken-grotesk",sans-serif;' + 'border:1px solid #335;border-radius:6px;padding:0;min-width:200px;max-width:320px;' + 'box-shadow:0 4px 20px rgba(0,0,0,0.6);text-shadow:0 1px 2px #000;' + 'transition:opacity 0.3s, transform 0.3s;'; var DOOR_NONE = 'none', DOOR_ALL = 'all', DOOR_HOVER = 'hover'; var CARD_TRAY_THRESHOLD = 4;

// ── Local mutable state (all card state lives here) ── var hoverEl = document.createElement('div'); hoverEl.style.cssText = CARD_CSS + 'pointer-events:none;'; document.body.appendChild(hoverEl); var hoverDie = null; var hoverPinned = false;

// landedCards on DC so dc-physics can check count/find entries at runtime DC.landedCards = []; var landedCards = DC.landedCards; // local alias (never reassigned, only mutated) var pendingLandings = [];

var cardTray = document.createElement('div'); cardTray.style.cssText = 'position:fixed;top:60px;right:12px;width:240px;max-height:70vh;overflow-y:auto;' + 'background:rgba(18,18,18,0.92);border:1px solid #335;border-radius:6px;z-index:150;display:none;' + 'font:12px "hanken-grotesk",sans-serif;pointer-events:auto;box-shadow:0 4px 16px rgba(0,0,0,0.6);'; document.body.appendChild(cardTray); var trayExpanded = true; var trayGroupState = {}; var lastRolledGroup = null; var selectedTrayIdx = -1;

var TRAY_GROUPS = [ { key: 'subu', label: 'Subu', color: '#cccccc', icon: '\u25C7', sub: [ { key: 'past', label: 'Past', color: '#A7A7A7' }, { key: 'today', label: 'Today', color: '#E1E1E1' }, { key: 'future', label: 'Future', color: '#A7A7A7' } ] }, { key: 'fid', label: 'FID', color: '#8B8B8B', icon: '\u2742', sub: [ { key: 'done', label: 'Past', color: '#A7A7A7' }, { key: 'active', label: 'Today', color: '#8B8B8B' }, { key: 'planned', label: 'Future', color: '#A7A7A7' } ] }, { key: 'grimoire', label: 'Grimoire', color: '#8B8B8B', icon: '\u2756' }, { key: 'field', label: 'Field', color: '#8B8B8B', icon: '\u2B22' } ];

// ── Card positioning ── function positionCardNearDie(el, die) { var pos = die.group.position.clone().project(camera); var sx = (pos.x * 0.5 + 0.5) * window.innerWidth; var sy = (-pos.y * 0.5 + 0.5) * window.innerHeight; el.style.left = Math.min(sx + 60, window.innerWidth - 340) + 'px'; el.style.top = Math.max(sy - 120, 10) + 'px'; }

// ── Card HTML ── function renderCardHTML(dieName, faceIdx, totalFaces, title, subtitle, actions) { var odds = (100 / totalFaces).toFixed(1) + '%'; var fLabel = 'face ' + (faceIdx + 1) + '/' + totalFaces + ' (' + odds + ')'; var btnHtml = ''; if (actions && actions.length > 0) { var primary = actions[0]; var bg = 'rgba(' + (primary.color || '74,144,217') + ',0.18)'; var fg = primary.fg || '#8B8B8B'; var mainStyle = 'display:flex;align-items:center;border-top:1px solid #333;border-radius:0 0 6px 6px;overflow:visible;position:relative;'; var actionStyle = 'flex:1;padding:7px 12px;cursor:pointer;font:bold 11px monospace;border:none;' + 'background:' + bg + ';color:' + fg + ';text-align:center;transition:background 0.15s;'; var chevStyle = 'width:28px;padding:7px 0;cursor:pointer;font:bold 11px monospace;border:none;' + 'border-left:1px solid rgba(255,255,255,0.08);background:' + bg + ';color:' + fg + ';text-align:center;' + 'transition:background 0.15s;border-radius:0 0 6px 0;'; btnHtml = '<div class="card-splitbtn" style="' + mainStyle + '">'; btnHtml += '<div class="card-action card-action-main" data-action="' + primary.key + '" style="' + actionStyle + '">' + primary.label + '</div>'; if (actions.length > 1) { btnHtml += '<div class="card-action-chevron" style="' + chevStyle + '">\u25BE</div>'; btnHtml += '<div class="card-dropdown" style="display:none;position:absolute;bottom:100%;right:0;' + 'background:#272727;border:1px solid #335;border-radius:4px;overflow:hidden;min-width:120px;z-index:200;box-shadow:0 -4px 12px rgba(0,0,0,0.5);">'; for (var ai = 1; ai < actions.length; ai++) { var a = actions[ai]; var aBg = 'rgba(' + (a.color || '74,144,217') + ',0.12)'; var aFg = a.fg || '#8B8B8B'; btnHtml += '<div class="card-action card-dropdown-item" data-action="' + a.key + '" style="padding:6px 12px;cursor:pointer;font:11px monospace;' + 'background:' + aBg + ';color:' + aFg + ';border-bottom:1px solid rgba(255,255,255,0.04);transition:background 0.15s;white-space:nowrap;">' + a.label + '</div>'; } btnHtml += '</div>'; } btnHtml += '</div>'; } return '<div style="padding:10px 14px 6px">' + '<div style="color:#667;font-size:11px;margin-bottom:4px">' + dieName + ' \u00b7 ' + fLabel + '</div>' + '<div style="color:#eef;font-size:16px;font-weight:bold;margin-bottom:4px">' + (title || '') + '</div>' + (subtitle ? '<div style="color:#889;font-size:12px;margin-bottom:6px;white-space:pre-line">' + subtitle + '</div>' : '') + '</div>' + btnHtml; }

// ── Hover card ── function showHoverCard(die, faceIdx, title, subtitle, actions, actionHandlers) { var wc = document.getElementById('world-canvas'); if (!wc || !wc.classList.contains('rb2-docked-world')) return; if (DC.cc('cards.mode', 'each') === DOOR_NONE) return; if (landedCards.length >= CARD_TRAY_THRESHOLD) return; for (var i = 0; i < landedCards.length; i++) { if (landedCards[i].die === die) return; } hoverDie = die; var totalF = die.solid.face_indices.length; var dieName = die.cube ? die.cube.onyx : die.solid.solid; var hasActions = actions && actions.length > 0; hoverEl.innerHTML = renderCardHTML(dieName, faceIdx, totalF, title, subtitle, actions || null); hoverEl.style.borderColor = hasActions ? '#8B8B8B' : '#335'; hoverEl.style.pointerEvents = hasActions ? 'auto' : 'none'; positionCardNearDie(hoverEl, die); hoverEl.style.display = 'block'; hoverEl.style.opacity = '1'; hoverEl.style.transform = 'translateY(0)'; if (hasActions && actionHandlers) { var btns = hoverEl.querySelectorAll('.card-action'); for (var bi = 0; bi < btns.length; bi++) { (function(btn) { var key = btn.getAttribute('data-action'); var handler = actionHandlers[key]; btn.onmouseover = function() { this.style.opacity = '0.8'; }; btn.onmouseout = function() { this.style.opacity = '1'; }; if (handler) btn.onclick = function(ev) { ev.stopPropagation(); hideHoverCard(); handler(); }; })(btns[bi]); } var chevron = hoverEl.querySelector('.card-action-chevron'); var dropdown = hoverEl.querySelector('.card-dropdown'); if (chevron && dropdown) { chevron.onmouseover = function() { this.style.opacity = '0.8'; }; chevron.onmouseout = function() { this.style.opacity = '1'; }; chevron.onclick = function(ev) { ev.stopPropagation(); dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; }; } } var uuidLinks = hoverEl.querySelectorAll('.uuid-link'); if (uuidLinks.length > 0) { hoverEl.style.pointerEvents = 'auto'; for (var ui = 0; ui < uuidLinks.length; ui++) { (function(link) { link.onmouseover = function() { this.style.color = '#A7A7A7'; }; link.onmouseout = function() { this.style.color = '#8B8B8B'; }; link.onclick = function(ev) { ev.stopPropagation(); var uuid = link.getAttribute('data-uuid'); if (uuid) { hideHoverCard(); DC.popThingIntoDice(uuid, die); } }; })(uuidLinks[ui]); } } if (hasActions || uuidLinks.length > 0) { hoverEl.onmouseenter = function() { hoverPinned = true; }; hoverEl.onmouseleave = function() { hoverPinned = false; hideHoverCard(); }; } else { hoverEl.onmouseenter = null; hoverEl.onmouseleave = null; } }

function hideHoverCard() { if (hoverPinned) return; hoverEl.style.display = 'none'; hoverEl.style.pointerEvents = 'none'; hoverDie = null; }

// ── Landed card ── function showLandedCard(die, faceIdx, title, subtitle, actions, actionHandlers) { hideLandedCard(die); var el = document.createElement('div'); el.style.cssText = CARD_CSS + 'pointer-events:auto;'; document.body.appendChild(el); var totalF = die.solid.face_indices.length; var dieName = die.cube ? die.cube.onyx : die.solid.solid; el.innerHTML = renderCardHTML(dieName, faceIdx, totalF, title, subtitle, actions); el.style.borderColor = (actions && actions.length > 0) ? '#8B8B8B' : '#E1E1E1'; positionCardNearDie(el, die); el.style.display = 'block'; el.style.opacity = '0'; el.style.transform = 'translateY(10px)'; requestAnimationFrame(function() { el.style.opacity = '1'; el.style.transform = 'translateY(0)'; }); if (actionHandlers) { setTimeout(function() { var btns = el.querySelectorAll('.card-action'); for (var bi = 0; bi < btns.length; bi++) { (function(btn) { var key = btn.getAttribute('data-action'); var handler = actionHandlers[key]; btn.onmouseover = function() { this.style.opacity = '0.8'; }; btn.onmouseout = function() { this.style.opacity = '1'; }; if (handler) btn.onclick = function(ev) { ev.stopPropagation(); hideLandedCard(die); handler(); }; })(btns[bi]); } var chevron = el.querySelector('.card-action-chevron'); var dropdown = el.querySelector('.card-dropdown'); if (chevron && dropdown) { chevron.onmouseover = function() { this.style.opacity = '0.8'; }; chevron.onmouseout = function() { this.style.opacity = '1'; }; chevron.onclick = function(ev) { ev.stopPropagation(); dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; }; var items = dropdown.querySelectorAll('.card-dropdown-item'); for (var di2 = 0; di2 < items.length; di2++) { (function(item) { var origClick = item.onclick; item.onclick = function(ev) { dropdown.style.display = 'none'; if (origClick) origClick.call(this, ev); else { var k = item.getAttribute('data-action'); if (actionHandlers[k]) { ev.stopPropagation(); hideLandedCard(die); actionHandlers[k](); } } }; })(items[di2]); } } var mainBtn = el.querySelector('.card-action-main'); if (dropdown && mainBtn) { var dropItems = dropdown.querySelectorAll('.card-dropdown-item'); for (var dj = 0; dj < dropItems.length; dj++) { (function(item) { item.addEventListener('mousedown', function(ev) { var mainKey = mainBtn.getAttribute('data-action'); var mainLabel = mainBtn.textContent; var mainFg = mainBtn.style.color; var itemKey = item.getAttribute('data-action'); var itemLabel = item.textContent; var itemFg = item.style.color; mainBtn.setAttribute('data-action', itemKey); mainBtn.textContent = itemLabel; mainBtn.style.color = itemFg; var handler = actionHandlers[itemKey]; if (handler) mainBtn.onclick = function(ev2) { ev2.stopPropagation(); hideLandedCard(die); handler(); }; item.setAttribute('data-action', mainKey); item.textContent = mainLabel; item.style.color = mainFg; var oldHandler = actionHandlers[mainKey]; if (oldHandler) item.onclick = function(ev2) { ev2.stopPropagation(); dropdown.style.display = 'none'; hideLandedCard(die); oldHandler(); }; for (var lci = 0; lci < landedCards.length; lci++) { if (landedCards[lci].die === die) { landedCards[lci].enterFn = handler || null; } } dropdown.style.display = 'none'; ev.preventDefault(); ev.stopPropagation(); }); })(dropItems[dj]); } } }, 50); } setTimeout(function() { var uuidLinks = el.querySelectorAll('.uuid-link'); for (var ui = 0; ui < uuidLinks.length; ui++) { (function(link) { link.onmouseover = function() { this.style.color = '#A7A7A7'; }; link.onmouseout = function() { this.style.color = '#8B8B8B'; }; link.onclick = function(ev) { ev.stopPropagation(); var uuid = link.getAttribute('data-uuid'); if (uuid) DC.popThingIntoDice(uuid, die); }; })(uuidLinks[ui]); } }, 60); var firstEnterFn = null; if (actionHandlers) { firstEnterFn = actionHandlers.enter || actionHandlers.smash || null; } var lcEntry = { die: die, el: el, faceIdx: faceIdx, enterFn: firstEnterFn, actions: actions || null, actionHandlers: actionHandlers || null, title: title, subtitle: subtitle }; landedCards.push(lcEntry); if (hoverDie === die) hideHoverCard(); updateCardTray(); }

function hideLandedCard(die) { for (var i = landedCards.length - 1; i >= 0; i--) { if (landedCards[i].die === die) { if (landedCards[i].el.parentNode) landedCards[i].el.parentNode.removeChild(landedCards[i].el); landedCards.splice(i, 1); } } updateCardTray(); }

function hideAllCards() { hideHoverCard(); for (var i = 0; i < landedCards.length; i++) { if (landedCards[i].el.parentNode) landedCards[i].el.parentNode.removeChild(landedCards[i].el); } landedCards.length = 0; pendingLandings = []; selectedTrayIdx = -1; updateCardTray(); for (var wd = 0; wd < dice.length; wd++) { if (dice[wd].activeWisp) { var aw = dice[wd].activeWisp; aw.wisp.visible = false; aw.wisp.userData.state = 'dormant'; aw.wisp.userData.core.material.opacity = 0; aw.wisp.userData.shell.material.opacity = 0; aw.wisp.userData.light.intensity = 0; if (aw.miniDie) { scene.remove(aw.miniDie); aw.miniDie.userData.mesh.geometry.dispose(); aw.miniDie.userData.mesh.material.dispose(); aw.miniDie.userData.wire.geometry.dispose(); aw.miniDie.userData.wire.material.dispose(); } if (aw.miniLabel && aw.miniLabel.parentNode) { aw.miniLabel.parentNode.removeChild(aw.miniLabel); } dice[wd].activeWisp = null; } } }

// ── Card Tray ── function classifyCard(lc) { var role = (lc.die.cube && lc.die.cube.subuRole) || 'dice'; var fi = lc.die.cube && lc.die.cube.faces && lc.die.cube.faces[lc.faceIdx]; if (role === 'popped' || role === 'smash') { var ancestor = lc.die.cube && lc.die.cube.parentDie; var depth = 0; while (ancestor && depth < 10) { var aRole = (ancestor.cube && ancestor.cube.subuRole) || 'dice'; if (aRole !== 'popped' && aRole !== 'smash') { return classifyByRole(aRole, fi, ancestor); } ancestor = ancestor.cube && ancestor.cube.parentDie; depth++; } } return classifyByRole(role, fi, null); }

function classifyByRole(role, fi, ancestorDie) { if (role === 'past' || role === 'present' || role === 'future') { var sub = role === 'present' ? 'today' : role; return { group: 'subu', sub: sub, fi: fi }; } if (role === 'grimoire') { return { group: 'grimoire', sub: 'grimoire', fi: fi }; } if (role === 'fid') { var sub2 = 'planned'; if (fi && fi.gap_total > 0 && fi.gap_done >= fi.gap_total) sub2 = 'done'; else if (fi && fi.gap_active > 0) sub2 = 'active'; if (!sub2 || sub2 === 'planned') { var af = ancestorDie && ancestorDie.cube && ancestorDie.cube.faces; var aLanded = ancestorDie && ancestorDie.landedFace >= 0 ? ancestorDie.landedFace : -1; if (af && aLanded >= 0 && af[aLanded]) { var afi = af[aLanded]; if (afi.gap_total > 0 && afi.gap_done >= afi.gap_total) sub2 = 'done'; else if (afi.gap_active > 0) sub2 = 'active'; } } return { group: 'fid', sub: sub2, fi: fi }; } return { group: 'field', sub: role, fi: fi }; }

function updateCardTray() { if (landedCards.length < CARD_TRAY_THRESHOLD) { cardTray.style.display = 'none'; for (var i = 0; i < landedCards.length; i++) landedCards[i].el.style.display = 'block'; return; } for (var j = 0; j < landedCards.length; j++) landedCards[j].el.style.display = 'none'; cardTray.style.display = 'block'; renderCardTray(); }

function renderCardTray() { var classified = []; for (var i = 0; i < landedCards.length; i++) { var c = classifyCard(landedCards[i]); c.lc = landedCards[i]; c.idx = i; classified.push(c); } var linkLines = DC.linkLines || []; var linksVisible = DC.linksVisible !== false; var hasLinks = linkLines.length > 0; var html = '<div style="padding:8px 10px;border-bottom:1px solid #333;display:flex;justify-content:space-between;align-items:center">' + '<span style="color:#8B8B8B">' + landedCards.length + ' cards</span>' + '<span style="display:flex;align-items:center;gap:8px">' + (hasLinks ? '<label class="tray-links-toggle" style="cursor:pointer;color:#556;font-size:9px;display:flex;align-items:center;gap:3px">' + '<input type="checkbox"' + (linksVisible ? ' checked' : '') + ' style="margin:0;accent-color:#8B8B8B;width:10px;height:10px"> links</label>' : '') + '<span class="tray-toggle" style="cursor:pointer;color:#8B8B8B;font-size:10px">' + (trayExpanded ? '\u25B2' : '\u25BC') + '</span>' + '</span></div>'; if (trayExpanded) { var sortedGroups = TRAY_GROUPS.slice(); if (lastRolledGroup) { sortedGroups.sort(function(a, b) { var aHot = a.key === lastRolledGroup ? 0 : 1; var bHot = b.key === lastRolledGroup ? 0 : 1; return aHot - bHot; }); } for (var gi = 0; gi < sortedGroups.length; gi++) { var gdef = sortedGroups[gi]; var gItems = []; for (var ci = 0; ci < classified.length; ci++) { if (classified[ci].group === gdef.key) gItems.push(classified[ci]); } if (gItems.length === 0) continue; var gKey = gdef.key; var gCollapsed = trayGroupState[gKey] === false; html += '<div style="border-bottom:1px solid rgba(255,255,255,0.06)">'; html += '<div class="tray-group-hdr" data-gkey="' + gKey + '" style="padding:6px 10px;cursor:pointer;color:' + gdef.color + ';font-size:11px;font-weight:bold;display:flex;justify-content:space-between;align-items:center">' + '<span>' + (gdef.icon || '') + ' ' + gdef.label + ' (' + gItems.length + ')</span>' + '<span style="font-size:9px;color:#556">' + (gCollapsed ? '\u25BC' : '\u25B2') + '</span></div>'; if (!gCollapsed) { if (gdef.sub) { for (var si = 0; si < gdef.sub.length; si++) { var sdef = gdef.sub[si]; var sItems = []; for (var sci = 0; sci < gItems.length; sci++) { if (gItems[sci].sub === sdef.key) sItems.push(gItems[sci]); } if (sItems.length === 0) continue; html += '<div style="padding:2px 0">'; html += '<div style="padding:2px 10px 2px 18px;color:' + sdef.color + ';font-size:9px;opacity:0.7;text-transform:uppercase">' + sdef.label + ' (' + sItems.length + ')</div>'; for (var sii = 0; sii < sItems.length; sii++) { html += renderTrayItem(sItems[sii]); } html += '</div>'; } } else { var fieldSubs = {}; for (var fi2 = 0; fi2 < gItems.length; fi2++) { var fs = gItems[fi2].sub; if (!fieldSubs[fs]) fieldSubs[fs] = []; fieldSubs[fs].push(gItems[fi2]); } var fsKeys = Object.keys(fieldSubs); for (var fsi = 0; fsi < fsKeys.length; fsi++) { var fsItems = fieldSubs[fsKeys[fsi]]; if (fsKeys.length > 1) { html += '<div style="padding:2px 10px 2px 18px;color:#667;font-size:9px;text-transform:uppercase">' + fsKeys[fsi] + ' (' + fsItems.length + ')</div>'; } for (var fsii = 0; fsii < fsItems.length; fsii++) { html += renderTrayItem(fsItems[fsii]); } } } } html += '</div>'; } } cardTray.innerHTML = html; wireTray(); }

function dieGlyphSVG(die) { if (!die || !die.solid || !die.solid.face_indices) return ''; var p = die.solid.face_indices[0].length; var n = die.solid.face_indices.length; var sz = 16, cx = sz/2, cy = sz/2, r = 6.5; var pts = []; for (var i = 0; i < p; i++) { var a = -Math.PI/2 + i * 2 * Math.PI / p; pts.push((cx + r * Math.cos(a)).toFixed(1) + ',' + (cy + r * Math.sin(a)).toFixed(1)); } return '<svg width="' + sz + '" height="' + sz + '" viewBox="0 0 ' + sz + ' ' + sz + '" style="vertical-align:middle;margin-right:4px">' + '<polygon points="' + pts.join(' ') + '" fill="none" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>' + '<text x="' + cx + '" y="' + (cy + 3.5) + '" text-anchor="middle" fill="currentColor" font-size="8" font-family="monospace">' + n + '</text></svg>'; }

function renderTrayItem(c) { var fi = c.fi; var title = fi ? (fi.title || fi.onyx || '') : 'face ' + (c.lc.faceIdx + 1); var glyph = dieGlyphSVG(c.lc.die); var isSelected = c.idx === selectedTrayIdx; var pill = ''; if (fi && fi.subu_type === 'gem_face' && fi.gem_name) { var gemColor = GEM_COLORS[fi.gem_name] || 0x888888; var gemHex = '#' + ('000000' + gemColor.toString(16)).slice(-6); var pillBg = fi.gem_name === 'onyx' ? 'background:rgba(255,255,255,0.85);color:#111;' : 'color:' + gemHex + ';'; pill = '<span style="display:inline-block;vertical-align:middle;margin-right:5px;' + 'padding:1px 6px;border:1.5px solid ' + gemHex + ';border-radius:8px;' + 'font-size:8px;' + pillBg + 'line-height:12px;white-space:nowrap">' + fi.gem_name + '</span>'; } var showTitle = pill ? '' : '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + title + '</span>'; var itemStyle = 'padding:4px 10px 4px 22px;cursor:pointer;color:' + (isSelected ? '#eef' : '#aab') + ';transition:background 0.15s;' + (isSelected ? 'background:rgba(139,139,139,0.18);' : '') + 'overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center'; var html = '<div class="tray-item" data-tray-idx="' + c.idx + '" style="' + itemStyle + '">' + glyph + pill + showTitle + '</div>'; if (isSelected) { html += '<div class="tray-detail" data-tray-idx="' + c.idx + '" style="padding:4px 10px 6px 26px;border-bottom:1px solid rgba(255,255,255,0.06)">'; if (c.lc.subtitle) { html += '<div style="color:#778;font-size:10px;margin-bottom:4px;white-space:pre-line;max-height:40px;overflow:hidden">' + c.lc.subtitle.substring(0, 120) + '</div>'; } html += '<div style="display:flex;gap:4px;flex-wrap:wrap">'; html += '<div class="tray-action" data-tray-act="roll" data-tray-idx="' + c.idx + '" style="padding:3px 8px;cursor:pointer;font:bold 10px monospace;border-radius:3px;background:rgba(167,167,167,0.15);color:#6c6">&#x1F3B2; Roll</div>'; var acts = c.lc.actions; if (acts) { for (var ai = 0; ai < acts.length; ai++) { var a = acts[ai]; var aBg = 'rgba(' + (a.color || '74,144,217') + ',0.15)'; var aFg = a.fg || '#8B8B8B'; html += '<div class="tray-action" data-tray-act="' + a.key + '" data-tray-idx="' + c.idx + '" style="padding:3px 8px;cursor:pointer;font:bold 10px monospace;border-radius:3px;background:' + aBg + ';color:' + aFg + '">' + a.label + '</div>'; } } html += '</div></div>'; } return html; }

function wireTray() { var toggle = cardTray.querySelector('.tray-toggle'); if (toggle) toggle.onclick = function() { trayExpanded = !trayExpanded; renderCardTray(); }; var linksToggle = cardTray.querySelector('.tray-links-toggle input'); if (linksToggle) linksToggle.onchange = function() { DC.setLinksVisible(this.checked); }; var ghdrs = cardTray.querySelectorAll('.tray-group-hdr'); for (var gi = 0; gi < ghdrs.length; gi++) { (function(hdr) { hdr.onclick = function() { var gk = hdr.getAttribute('data-gkey'); trayGroupState[gk] = trayGroupState[gk] === false ? true : false; renderCardTray(); }; })(ghdrs[gi]); } var trayItems = cardTray.querySelectorAll('.tray-item'); for (var ti = 0; ti < trayItems.length; ti++) { (function(item) { var idx = parseInt(item.getAttribute('data-tray-idx')); var lc3 = landedCards[idx]; if (!lc3) return; item.onmouseover = function() { if (idx !== selectedTrayIdx) this.style.background = 'rgba(139,139,139,0.1)'; DC.glowDie(lc3.die, null, 0.5); }; item.onmouseout = function() { if (idx !== selectedTrayIdx) this.style.background = 'transparent'; if (lc3.die.landedFace >= 0) DC.settleGlow(lc3.die); else DC.unglowDie(lc3.die); }; item.onclick = function() { selectedTrayIdx = selectedTrayIdx === idx ? -1 : idx; renderCardTray(); }; })(trayItems[ti]); } var trayActions = cardTray.querySelectorAll('.tray-action'); for (var ta = 0; ta < trayActions.length; ta++) { (function(btn) { var idx2 = parseInt(btn.getAttribute('data-tray-idx')); var act = btn.getAttribute('data-tray-act'); var lc4 = landedCards[idx2]; if (!lc4) return; btn.onmouseover = function() { this.style.opacity = '0.7'; }; btn.onmouseout = function() { this.style.opacity = '1'; }; btn.onclick = function(ev) { ev.stopPropagation(); if (act === 'roll') { selectedTrayIdx = -1; DC.rollDie(lc4.die); return; } if (lc4.actionHandlers && lc4.actionHandlers[act]) { selectedTrayIdx = -1; hideLandedCard(lc4.die); lc4.actionHandlers[act](); } }; })(trayActions[ta]); } }

// ── Action builders ── function actionsEnter(enterFn) { return { actions: [{ key: 'enter', label: '\u27F6 Enter', color: '74,144,217', fg: '#8B8B8B' }], handlers: { enter: enterFn } }; }

function actionsThing(thingId, thingName, enterFn, smashFn, sourceDie) { var acts = [], hdl = {}; if (enterFn) { acts.push({ key: 'enter', label: '\u27F6 Enter', color: '74,144,217', fg: '#8B8B8B' }); hdl.enter = enterFn; } if (thingId) { acts.push({ key: 'gaze', label: '\u25C8 Gaze', color: '238,232,213', fg: '#E1E1E1' }); hdl.gaze = function() { DC.popThingIntoDice(thingId, sourceDie || null); }; } if (smashFn) { acts.push({ key: 'smash', label: '\u2726 Smash', color: '255,170,68', fg: '#C3C3C3' }); hdl.smash = smashFn; } return { actions: acts, handlers: hdl }; }

function actionsNone() { return { actions: null, handlers: null }; }

function actionsFaceDie(enterFaceFn, smashFaceFn, enterDieFn, smashDieFn) { var acts = [], hdl = {}; if (enterFaceFn) { acts.push({ key: 'enter', label: '\u27F6 Enter Face', color: '74,144,217', fg: '#8B8B8B' }); hdl.enter = enterFaceFn; } if (smashFaceFn) { acts.push({ key: 'smash', label: '\u2726 Smash Face', color: '255,170,68', fg: '#C3C3C3' }); hdl.smash = smashFaceFn; } if (enterDieFn) { acts.push({ key: 'enterDie', label: '\u27F6 Enter Die', color: '50,120,180', fg: '#707070' }); hdl.enterDie = enterDieFn; } if (smashDieFn) { acts.push({ key: 'smashDie', label: '\u2726 Smash Die', color: '200,140,50', fg: '#A7A7A7' }); hdl.smashDie = smashDieFn; } return { actions: acts, handlers: hdl }; }

function hoverActionsForFace(fI, die) { if (!fI || fI.onyx === 'empty' || fI.subu_type === 'empty') return null; if (fI.subu_type === 'thing' && fI.id) { return actionsThing(fI.id, fI.title || fI.onyx, null, null, die); } if (fI.subu_type === 'gem_face') return null; if (fI.subu_type === 'fid_entry' && fI.fid_id) { return actionsThing(fI.fid_id, fI.fid_title || fI.onyx, null, null, die); } return null; }

// ── Smash / Pop ── function popThingIntoDice(thingId, nearDie) { DC.hud.textContent = 'Loading ' + thingId.substring(0, 8) + '...'; fetch('/api/things/doc/' + thingId) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.sacred || !data.gem_values) { DC.hud.textContent = 'Could not load ' + thingId; return; } var gv = data.gem_values; var name = gv.onyx || thingId.substring(0, 8); var sapphire = gv.sapphire || '?'; var sx = nearDie ? nearDie.group.position.x + 3 : 0; var sz = nearDie ? nearDie.group.position.z : 0; var gemCount = 0; for (var gi = 0; gi < GEM_NAMES.length; gi++) { if (gv[GEM_NAMES[gi]] != null && gv[GEM_NAMES[gi]] !== '') gemCount++; } var pq = DC.solidForCount(Math.max(gemCount, 4)); DC.fetchSolid(pq[0], pq[1]).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var faces = [], fColors = {}; for (var fi = 0; fi < tf; fi++) { if (fi < GEM_NAMES.length) { var gName = GEM_NAMES[fi]; var gVal = gv[gName]; var hasData = gVal != null && gVal !== ''; faces.push({ onyx: gName, title: gName, face_number: fi + 1, subu_type: 'gem_face', gem_name: gName, gem_label: GEM_LABELS[gName], gem_data: gVal, has_data: hasData }); fColors[fi] = hasData ? GEM_COLORS[gName] : DC.dimColor(GEM_COLORS[gName], 0.3); } else { faces.push({ onyx: 'empty', title: '', face_number: fi + 1, subu_type: 'empty' }); fColors[fi] = 0x111111; } } DC.createDie(sd, sx, sz, { onyx: name, title: name + ' (' + sapphire + ')', faces: faces, subuRole: 'popped', subuLevel: 'gem_face', poppedThingId: thingId, parentDie: nearDie || null, parentFace: nearDie ? nearDie.landedFace : -1 }, fColors, 0.8); DC.hud.textContent = name + ' popped into field \u00b7 roll to inspect gems'; }); }) .catch(function(e) { console.warn('Pop failed:', e); DC.hud.textContent = 'Failed to load ' + thingId; }); }

function smashDie(die, items, label, baseColor) { var cx = die.group.position.x, cz = die.group.position.z; var count = items.length; if (count === 0) { DC.hud.textContent = label + ' \u2014 empty (nothing to smash)'; return; } var faceData = items.map(function(item, i) { return { onyx: item.onyx || item.title || 'item', title: item.title || item.onyx || 'item', face_number: i + 1, sapphire: item.sapphire, subu_type: 'thing', id: item.id, preview: item.preview || '' }; }); fetch('/api/platonic/pack/' + count) .then(function(r) { return r.json(); }) .then(function(pack) { if (pack.error || !pack.dice || pack.dice.length === 0) { DC.spawnColoredDie(cx + 3, cz, 'smash', faceData, baseColor, label); return; } var solids = pack.dice; var radius = Math.max(2.5, solids.length * 1.2); var off = 0; solids.forEach(function(s, si) { var used = Math.min(s.faces, count - off); var chunk = faceData.slice(off, off + used); off += used; var angle = (si / solids.length) * Math.PI * 2; var sx = cx + Math.cos(angle) * radius; var sz = cz + Math.sin(angle) * radius; DC.fetchSolid(s.schlafli.p, s.schlafli.q).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var fC = {}; for (var fi = 0; fi < tf; fi++) { var t = tf > 1 ? fi / (tf - 1) : 0.5; fC[fi] = DC.dimColor(baseColor, 0.6 + t * 0.4); } var padded = []; for (var fi2 = 0; fi2 < tf; fi2++) { padded.push(fi2 < chunk.length ? chunk[fi2] : { onyx: 'empty', title: '', face_number: fi2 + 1, subu_type: 'empty' }); } var dLabel = solids.length > 1 ? label + ' (' + (si + 1) + '/' + solids.length + ')' : label; DC.createDie(sd, sx, sz, { onyx: dLabel, title: dLabel, faces: padded, subuRole: 'smash', subuLevel: 'thing', parentDie: die, parentFace: die.landedFace }, fC, 0.7); }); }); DC.hud.textContent = label + ' \u00b7 ' + count + ' smashed \u00b7 ' + solids.length + ' ' + (solids.length === 1 ? 'die' : 'dice'); }) .catch(function() { DC.spawnColoredDie(cx + 3, cz, 'smash', faceData, baseColor, label); }); }

function smashGrouped(die, items, label, baseColor) { var cx = die.group.position.x, cz = die.group.position.z; if (items.length === 0) { DC.hud.textContent = label + ' \u2014 empty'; return; } if (items.length <= 20) { smashDie(die, items, label, baseColor); return; } var groups = {}; for (var i = 0; i < items.length; i++) { var sap = items[i].sapphire || 'unknown'; if (!groups[sap]) groups[sap] = []; groups[sap].push(items[i]); } var gKeys = Object.keys(groups).sort(function(a, b) { return groups[b].length - groups[a].length; }); var radius = Math.max(3, gKeys.length * 1.5); var totalSpawned = 0; gKeys.forEach(function(sap, gi) { var gItems = groups[sap]; var angle = (gi / gKeys.length) * Math.PI * 2; var gx = cx + Math.cos(angle) * radius; var gz = cz + Math.sin(angle) * radius; var faceData = gItems.map(function(item, i2) { return { onyx: item.onyx || item.title || 'item', title: item.title || item.onyx || 'item', face_number: i2 + 1, sapphire: item.sapphire, subu_type: 'thing', id: item.id, preview: item.preview || '' }; }); var hueShift = gi / gKeys.length; var gc = new THREE.Color(baseColor); gc.offsetHSL(hueShift * 0.5, 0, 0); var groupColor = gc.getHex(); var groupLabel = sap + ' (' + gItems.length + ')'; var pq = DC.solidForCount(Math.min(gItems.length, 20)); DC.fetchSolid(pq[0], pq[1]).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var fC = {}; for (var fi = 0; fi < tf; fi++) { var t = tf > 1 ? fi / (tf - 1) : 0.5; fC[fi] = DC.dimColor(groupColor, 0.5 + t * 0.5); } var padded = []; for (var fi2 = 0; fi2 < tf; fi2++) { padded.push(fi2 < faceData.length ? faceData[fi2] : { onyx: 'empty', title: '', face_number: fi2 + 1, subu_type: 'empty' }); } DC.createDie(sd, gx, gz, { onyx: groupLabel, title: groupLabel, faces: padded, subuRole: 'smash', subuLevel: 'thing', parentDie: die, parentFace: die.landedFace }, fC, 0.8); }); totalSpawned += gItems.length; }); DC.hud.textContent = label + ' \u00b7 ' + items.length + ' things \u00b7 ' + gKeys.length + ' types'; }

// ── Landing ── function handleLanding(die, faceIdx, title, subtitle, canEnterOrActions, enterFnOrHandlers) { var actions, handlers; if (canEnterOrActions && typeof canEnterOrActions === 'object' && canEnterOrActions.actions) { actions = canEnterOrActions.actions; handlers = canEnterOrActions.handlers; } else if (canEnterOrActions === true && enterFnOrHandlers) { var a = actionsEnter(enterFnOrHandlers); actions = a.actions; handlers = a.handlers; } else { actions = null; handlers = null; } var mode = DC.cc('cards.mode', 'each'); if (mode === DOOR_NONE || mode === DOOR_HOVER) return; if (mode === DOOR_ALL) { pendingLandings.push(function() { DC.startWispEmerge(die, faceIdx); }); checkAllSettled(); return; } DC.startWispEmerge(die, faceIdx); }

function checkAllSettled() { for (var d = 0; d < dice.length; d++) { if (!dice[d].settled) return; } for (var i = 0; i < pendingLandings.length; i++) pendingLandings[i](); pendingLandings = []; }

// ── Exports ── DC.showHoverCard = showHoverCard; DC.hideHoverCard = hideHoverCard; DC.showLandedCard = showLandedCard; DC.hideLandedCard = hideLandedCard; DC.hideAllCards = hideAllCards; DC.updateCardTray = updateCardTray; DC.handleLanding = handleLanding; DC.hoverActionsForFace = hoverActionsForFace; DC.actionsEnter = actionsEnter; DC.actionsThing = actionsThing; DC.actionsNone = actionsNone; DC.actionsFaceDie = actionsFaceDie; DC.popThingIntoDice = popThingIntoDice; DC.smashDie = smashDie; DC.smashGrouped = smashGrouped; // getLandedCard: used by dc-physics conjureCard to find the DOM el after showLandedCard DC.getLandedCard = function(die, faceIdx) { for (var i = 0; i < landedCards.length; i++) { if (landedCards[i].die === die && landedCards[i].faceIdx === faceIdx) return landedCards[i]; } return null; }; DC.hoverEl = hoverEl;

console.log('dc-cards: Card system initialized'); })();