// packs_preview.js — opens a model in the vendored Blockbench app inside a // full-screen overlay. The geo JSON is loaded via the gated pack_asset // endpoint and the texture images are resolved from afw_bone_textures (or the // vanilla entity skin fallback) and handed to Blockbench; the bundled // GeckoLib + Multi Actor Animator plugins handle applying them. (function () { let overlay = null; let iframe = null; let busy = false; const BB_URL = '/static/vendor/blockbench/index.html'; function ensureOverlay() { if (overlay) return overlay; overlay = document.createElement('div'); overlay.className = 'bb-overlay'; overlay.innerHTML = '
' + '' + '' + '
' + '
'; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.querySelector('#bb-overlay-close').addEventListener('click', close); document.body.appendChild(overlay); return overlay; } function setStatus(text) { const el = document.querySelector('#bb-overlay-status'); if (el) el.textContent = text || ''; } function close() { if (!overlay) return; overlay.hidden = true; if (iframe) { iframe.remove(); iframe = null; } } function assetUrl(baseUrl, member) { return baseUrl.replace('ASSET', member); } function entityNameFromMember(member) { let base = member.slice(member.lastIndexOf('/') + 1); if (base.endsWith('.geo.json')) base = base.slice(0, -'.geo.json'.length); for (const s of ['.mf', '.fm', '.m', '.f', '.g']) { if (base.endsWith(s)) { base = base.slice(0, -s.length); break; } } return base; } function resourceToMember(resource) { resource = String(resource); if (resource.indexOf(':') !== -1) { const p = resource.split(':'); return 'assets/' + p.shift() + '/' + p.join(':'); } if (resource.startsWith('assets/')) return resource; return 'assets/' + resource; } function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('Could not load ' + url)); img.src = url; }); } function imageDataUrl(img) { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; c.getContext('2d').drawImage(img, 0, 0); return c.toDataURL('image/png'); } async function fetchGeo(member, baseUrl) { const r = await fetch(assetUrl(baseUrl, member), { headers: { 'Accept': 'application/json' } }); if (!r.ok) throw new Error('Could not load the model file'); return r.json(); } async function fetchModelText(member, baseUrl) { const r = await fetch(assetUrl(baseUrl, member), { headers: { 'Accept': 'application/json' } }); if (!r.ok) throw new Error('Could not load the model file'); return r.text(); } // Formats whose textures are baked into the file (nothing to resolve). // glTF/GLB are NOT here — import needs the desktop-only glTF Importer // plugin, so those models are marked "Incompatible" instead. const EMBEDDED_FORMATS = ['.bbmodel']; async function fetchVanillaIndex(vanillaBaseUrl) { try { const r = await fetch(assetUrl(vanillaBaseUrl, 'entity_index.json')); return r.ok ? await r.json() : {}; } catch (e) { return {}; } } // Resolve the textures to hand to Blockbench: the pack's _features // overlay(s) plus the vanilla entity skin (a random fur/colour variant when // the mob has several). Blockbench + the bundled plugins UV-wrap and apply // them, so we don't worry about per-bone assignment here. async function resolveTextures(geo, entity, opts, index) { const featureTextures = []; const baseTextures = []; const afw = geo['afw_bone_textures'] || {}; // 1. Feature overlays — from afw_bone_textures, or the conventional // assets//textures/entity//_features.png path. const members = []; let namespace = 'needsofnature'; for (const k in afw) { const m = resourceToMember(afw[k]); if (members.indexOf(m) === -1) members.push(m); const colon = String(afw[k]).indexOf(':'); if (colon > 0) namespace = String(afw[k]).slice(0, colon); } if (!members.length && entity) { members.push('assets/' + namespace + '/textures/entity/' + entity + '/' + entity + '_features.png'); } for (const m of members) { try { const img = await loadImage(assetUrl(opts.baseUrl, m)); featureTextures.push({ name: m.split('/').pop(), dataUrl: imageDataUrl(img) }); } catch (e) { console.warn('packs: skip feature texture', m, e); } } // 2. Vanilla base skin — a random variant when several exist, else the // server-matched pack skin, else none. let rel = null; const ent = index && index[entity]; if (ent) { const variants = ent.variants || (typeof ent === 'string' ? [ent] : [ent.default]); if (variants && variants.length) rel = variants[Math.floor(Math.random() * variants.length)]; } if (rel) { try { const img = await loadImage(assetUrl(opts.vanillaBaseUrl, 'entity/' + rel)); baseTextures.push({ name: rel.split('/').pop(), dataUrl: imageDataUrl(img) }); } catch (e) { console.warn('packs: skip vanilla skin', e); } } else if (opts.defaultTexture) { try { const img = await loadImage(assetUrl(opts.baseUrl, opts.defaultTexture)); baseTextures.push({ name: opts.defaultTexture.split('/').pop(), dataUrl: imageDataUrl(img) }); } catch (e) { console.warn('packs: skip default texture', e); } } // The vanilla/base texture must always be loaded first, then features. // Wide player models additionally always load L1Z0's custom skins // alongside the default Steve skin (tracked in static/player_skins/). if (entity === 'player') { for (const name of ['destroyed_skin.png', 'russian_goat.png']) { try { const img = await loadImage('/static/player_skins/' + name); baseTextures.push({ name: name, dataUrl: imageDataUrl(img) }); } catch (e) { console.warn('packs: skip player skin', name, e); } } } return baseTextures.concat(featureTextures); } // Create (or reset) the full-screen Blockbench overlay + fresh iframe and // wait for the app to be ready. Returns false on failure. async function prepareOverlay(name) { const overlayEl = ensureOverlay(); overlayEl.hidden = false; document.querySelector('#bb-overlay-title').textContent = name; setStatus('Starting Blockbench…'); if (iframe) iframe.remove(); iframe = document.createElement('iframe'); iframe.className = 'bb-frame'; iframe.src = BB_URL; overlayEl.appendChild(iframe); const ready = await new Promise((resolve) => { let done = false; const finish = (ok) => { if (done) return; done = true; window.removeEventListener('message', onMsg); clearTimeout(timer); resolve(ok); }; const timer = setTimeout(() => finish(false), 45000); const onMsg = (e) => { if (e.origin !== window.location.origin) return; if (e.data && e.data.type === 'packs-bb-ready') finish(true); }; window.addEventListener('message', onMsg); }); if (!ready) setStatus('Blockbench failed to start.'); return ready; } async function open(btn) { const member = btn.dataset.member; const name = btn.dataset.name || member; const modelFormat = btn.dataset.modelFormat || '.geo.json'; const slug = btn.dataset.projectSlug || ''; const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null }; if (!member || !opts.baseUrl || busy) return; busy = true; if (!(await prepareOverlay(name))) { busy = false; return; } setStatus('Loading model…'); try { const msg = { type: 'packs-open-model', name: name }; if (modelFormat === '.gltf' || modelFormat === '.glb') { setStatus('Incompatible — glTF/GLB preview needs the desktop glTF Importer plugin.'); busy = false; return; } // Pack plugins to hotload (autoload.txt), unless the user opted out. const noHotload = slug && localStorage.getItem('packs:no-hotload:' + slug) === '1'; if (!noHotload && btn.dataset.plugins) { try { const members = JSON.parse(btn.dataset.plugins); msg.plugins = members.map(m => ({ member: m, url: assetUrl(opts.baseUrl, m) })); } catch (e) { msg.plugins = []; } } if (EMBEDDED_FORMATS.indexOf(modelFormat) !== -1) { // Textures baked into the file — hand it to Blockbench as-is. msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) }; } else { const [geo, index] = await Promise.all([ fetchGeo(member, opts.baseUrl), fetchVanillaIndex(opts.vanillaBaseUrl), ]); const entity = entityNameFromMember(member); msg.geo = geo; msg.textures = await resolveTextures(geo, entity, opts, index); } iframe.contentWindow.postMessage(msg, window.location.origin); setStatus('Applying…'); } catch (e) { setStatus('Could not load model: ' + e.message); } busy = false; } // Load a Minecraft skin into Blockbench's built-in "Minecraft Skin" project. async function openSkin(btn) { const url = btn.dataset.url; const name = btn.dataset.name || 'skin'; if (!url || busy) return; busy = true; if (!(await prepareOverlay(name))) { busy = false; return; } setStatus('Loading skin…'); try { const select = btn.closest('.skin-card')?.querySelector('.skin-model-select'); const model = select ? select.value : 'steve'; const img = await loadImage(url); iframe.contentWindow.postMessage({ type: 'packs-open-skin', name: name, model: model, dataUrl: imageDataUrl(img), }, window.location.origin); setStatus('Applying…'); } catch (e) { setStatus('Could not load skin: ' + e.message); } busy = false; } window.addEventListener('message', (e) => { if (e.origin !== window.location.origin) return; if (!e.data) return; if (e.data.type === 'packs-model-open') { setStatus('Ready — drag to orbit, scroll to zoom.'); } else if (e.data.type === 'packs-model-error') { setStatus('Model error: ' + (e.data.error || '')); } else if (e.data.type === 'packs-plugin-error') { setStatus('Plugin error: ' + (e.data.error || '')); } }); window.PacksPreview = { open: open, openSkin: openSkin, close: close }; })();