Files
NoN-Site/nonpacks/static/js/packs_preview.js
T
2026-08-04 21:47:37 -05:00

260 lines
10 KiB
JavaScript

// 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 =
'<div class="bb-overlay-header">' +
'<span class="bb-overlay-title" id="bb-overlay-title"></span>' +
'<button type="button" class="bb-overlay-close" id="bb-overlay-close" aria-label="Close"><i class="fas fa-times"></i></button>' +
'</div>' +
'<div class="bb-overlay-status" id="bb-overlay-status"></div>';
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 <entity>_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/<ns>/textures/entity/<entity>/<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);
}
async function open(btn) {
const member = btn.dataset.member;
const name = btn.dataset.name || member;
const modelFormat = btn.dataset.modelFormat || '.geo.json';
const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null };
if (!member || !opts.baseUrl || busy) return;
busy = true;
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.');
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;
}
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;
}
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, close: close };
})();