(function () { const PLUGIN_ID = 'AnimationDirector'; const ACTOR_NAMES_PROPERTY = 'multiactor_actor_names'; const ACTOR_TEXTURES_PROPERTY = 'multiactor_actor_textures'; const BONE_TEXTURE_OVERRIDES_PROPERTY = 'multiactor_bone_texture_overrides'; const ACTOR_ANIMDEF_METADATA_PROPERTY = 'multiactor_actor_animdef_metadata'; const ANIMDEF_ANIMATORS_PROPERTY = 'multiactor_animdef_animators'; let importAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction; let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty; let actorTexturePreviewProjectListener; const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path']; let actorTexturePreviewTimeouts = []; // ---------------------------- // Helpers // ---------------------------- const num = (n, fallback = 0) => { const v = Number(n); return Number.isFinite(v) ? v : fallback; }; const v3 = (arr, fallback = 0) => Array.isArray(arr) ? [num(arr[0], fallback), num(arr[1], fallback), num(arr[2], fallback)] : [fallback, fallback, fallback]; const sanitizeFilePart = (name) => { let s = String(name || 'animation').trim(); // Windows-invalid filename chars + backslash without using escapes const backslash = String.fromCharCode(92); const bad = new Set([backslash, '/', ':', '*', '?', '"', '<', '>', '|']); let out = ''; for (const ch of s) out += bad.has(ch) ? '_' : ch; out = out.trim(); while (out.indexOf(' ') !== -1) out = out.split(' ').join(' '); return out; }; const clone = (obj) => JSON.parse(JSON.stringify(obj)); function getProjectStringMap(propertyName) { if (!Project) return {}; const raw = Project[propertyName]; if (typeof raw !== 'string' || !raw.trim()) return {}; try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; const map = {}; for (const [key, value] of Object.entries(parsed)) { const idx = parseInt(key, 10); const text = String(value || '').trim(); if (Number.isFinite(idx) && idx > 0 && text) map[String(idx)] = text; } return map; } catch (e) { console.warn(`Could not parse saved project map "${propertyName}":`, e); return {}; } } function setProjectStringMap(propertyName, values) { if (!Project) return; const clean = {}; for (const [key, value] of Object.entries(values || {})) { const idx = parseInt(key, 10); const text = String(value || '').trim(); if (Number.isFinite(idx) && idx > 0 && text) clean[String(idx)] = text; } Project[propertyName] = JSON.stringify(clean); Project.saved = false; } function getProjectActorNames() { return getProjectStringMap(ACTOR_NAMES_PROPERTY); } function setProjectActorNames(names) { setProjectStringMap(ACTOR_NAMES_PROPERTY, names); } function getProjectActorTextures() { return getProjectStringMap(ACTOR_TEXTURES_PROPERTY); } function setProjectActorTextures(textures) { setProjectStringMap(ACTOR_TEXTURES_PROPERTY, textures); } function uniqueTrimmedStrings(values) { const out = []; const seen = new Set(); for (const value of Array.isArray(values) ? values : []) { const text = String(value || '').trim(); if (!text || seen.has(text)) continue; seen.add(text); out.push(text); } return out; } function parseCommaSeparatedStrings(value) { return uniqueTrimmedStrings(String(value || '').split(',')); } function normalizeAnimatorNames(values) { const out = []; const seen = new Set(); for (const value of Array.isArray(values) ? values : []) { const text = String(value || '').trim(); const key = text.toLocaleLowerCase(); if (!text || seen.has(key)) continue; seen.add(key); out.push(text); } return out; } function getProjectAnimdefAnimators() { if (!Project) return []; return normalizeAnimatorNames(String(Project[ANIMDEF_ANIMATORS_PROPERTY] || '').split(',')); } function setProjectAnimdefAnimators(animators) { if (!Project) return; Project[ANIMDEF_ANIMATORS_PROPERTY] = normalizeAnimatorNames(animators).join(', '); Project.saved = false; } function cleanActorAnimdefMetadata(value) { const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const out = {}; const entityTypes = uniqueTrimmedStrings(source.entity_types); const actorTags = uniqueTrimmedStrings(source.actor_tags); const actorTagsAny = uniqueTrimmedStrings(source.actor_tags_any); const entityVariant = String(source.entity_variant || '').trim(); const activity = String(source.activity || '').trim().toLowerCase(); const injector = String(source.injector || '').trim().toUpperCase(); const propLeft = String(source.prop_left || '').trim(); const propRight = String(source.prop_right || '').trim(); if (entityTypes.length) out.entity_types = entityTypes; if (entityVariant) out.entity_variant = entityVariant; if (actorTags.length) out.actor_tags = actorTags; if (actorTagsAny.length) out.actor_tags_any = actorTagsAny; if (activity === 'active' || activity === 'passive') out.activity = activity; if (injector === 'V' || injector === 'A' || injector === 'M') out.injector = injector; if (source.receiver === true) out.receiver = true; if (propLeft) out.prop_left = propLeft; if (propRight) out.prop_right = propRight; return out; } function getProjectActorAnimdefMetadata() { if (!Project) return {}; const raw = Project[ACTOR_ANIMDEF_METADATA_PROPERTY]; if ((typeof raw !== 'string' || !raw.trim()) && (!raw || typeof raw !== 'object')) return {}; try { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; const map = {}; for (const [key, value] of Object.entries(parsed)) { const idx = parseInt(key, 10); if (!Number.isFinite(idx) || idx <= 0) continue; map[String(idx)] = cleanActorAnimdefMetadata(value); } return map; } catch (e) { console.warn(`Could not parse saved project map "${ACTOR_ANIMDEF_METADATA_PROPERTY}":`, e); return {}; } } function setProjectActorAnimdefMetadata(metadata) { if (!Project) return; const clean = {}; for (const [key, value] of Object.entries(metadata || {})) { const idx = parseInt(key, 10); if (!Number.isFinite(idx) || idx <= 0) continue; const entry = cleanActorAnimdefMetadata(value); if (Object.keys(entry).length) clean[String(idx)] = entry; } Project[ACTOR_ANIMDEF_METADATA_PROPERTY] = JSON.stringify(clean); Project.saved = false; } function getProjectBoneTextureOverrides() { if (!Project) return {}; const raw = Project[BONE_TEXTURE_OVERRIDES_PROPERTY]; if ((typeof raw !== 'string' || !raw.trim()) && (!raw || typeof raw !== 'object')) return {}; try { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; const map = {}; for (const [key, value] of Object.entries(parsed)) { if (!value || typeof value !== 'object' || Array.isArray(value)) continue; const uuid = String(value.uuid || key || '').trim(); const name = String(value.name || '').trim(); const texture = String(value.texture || '').trim(); const mapKey = uuid || name; if (mapKey && texture) map[mapKey] = { uuid, name, texture }; } return map; } catch (e) { console.warn(`Could not parse saved project map "${BONE_TEXTURE_OVERRIDES_PROPERTY}":`, e); return {}; } } function setProjectBoneTextureOverrides(overrides) { if (!Project) return; const clean = {}; for (const value of Object.values(overrides || {})) { if (!value || typeof value !== 'object' || Array.isArray(value)) continue; const uuid = String(value.uuid || '').trim(); const name = String(value.name || '').trim(); const texture = String(value.texture || '').trim(); const key = uuid || name; if (key && texture) clean[key] = { uuid, name, texture }; } Project[BONE_TEXTURE_OVERRIDES_PROPERTY] = JSON.stringify(clean); Project.saved = false; } function getActorExportLabel(actorIndex) { const names = getProjectActorNames(); const saved = String(names[String(actorIndex)] || '').trim(); return saved || `actor${actorIndex}`; } function getActorSelectLabel(actorIndex) { const label = getActorExportLabel(actorIndex); const fallback = `actor${actorIndex}`; return label === fallback ? fallback : `${fallback} (${label})`; } function buildActorSelectOptions() { const indices = getSortedUsedActorIndicesFromProject(); const options = {}; for (const idx of indices) options[String(idx)] = getActorSelectLabel(idx); return options; } const GENERIC_ACTOR_LABELS = new Set(['actor', 'top', 'bottom', 'anchor', 'root', 'entity', 'mob']); const RESOURCE_ID_PATTERN = /^[a-z0-9_.-]+:[a-z0-9/._-]+$/; function inferEntityTypeFromActorLabel(label) { let candidate = String(label || '').trim().toLowerCase(); candidate = candidate.replace(/[_-]?\d+$/, ''); if (!candidate || GENERIC_ACTOR_LABELS.has(candidate)) return ''; if (!/^[a-z0-9_]+$/.test(candidate)) return ''; return `minecraft:${candidate}`; } function actorAnimdefMetadataWithInference(actorIndex, metadata) { const clean = cleanActorAnimdefMetadata(metadata); if (Array.isArray(clean.entity_types) && clean.entity_types.length) return clean; const inferred = inferEntityTypeFromActorLabel(getActorExportLabel(actorIndex)); return inferred ? { ...clean, entity_types: [inferred] } : clean; } function addActorAnimdefFormFields(form, prefix, actorIndex, actorLabel, metadata, defaultActivity, condition) { const base = `${prefix}_${actorIndex}`; const fieldCondition = condition || undefined; form[`${base}_heading`] = { type: 'info', text: `actor${actorIndex}: ${actorLabel}`, condition: fieldCondition, }; form[`${base}_entity_types`] = { label: 'Entity types', type: 'text', value: (metadata.entity_types || []).join(', '), placeholder: 'minecraft:wolf', description: 'Comma-separated entity IDs.', condition: fieldCondition, }; form[`${base}_entity_variant`] = { label: 'Entity variant', type: 'text', value: metadata.entity_variant || '', placeholder: 'size_0', condition: fieldCondition, }; form[`${base}_actor_tags`] = { label: 'Required actor tags', type: 'text', value: (metadata.actor_tags || []).join(', '), placeholder: 'gender.male', condition: fieldCondition, }; form[`${base}_actor_tags_any`] = { label: 'Any actor tags', type: 'text', value: (metadata.actor_tags_any || []).join(', '), placeholder: 'tag.one, tag.two', condition: fieldCondition, }; form[`${base}_activity`] = { label: 'Activity', type: 'select', options: { active: 'Active', passive: 'Passive' }, value: metadata.activity || defaultActivity, condition: fieldCondition, }; form[`${base}_injector`] = { label: 'Injector role', type: 'select', options: { '': 'None', V: 'V', A: 'A', M: 'M' }, value: metadata.injector || '', condition: fieldCondition, }; form[`${base}_receiver`] = { label: 'Explicit receiver', type: 'checkbox', value: metadata.receiver === true, description: 'An actor cannot be both an injector and an explicit receiver.', condition: fieldCondition, }; form[`${base}_prop_left`] = { label: 'Default left prop', type: 'text', value: metadata.prop_left || '', placeholder: 'minecraft:carrot', condition: fieldCondition, }; form[`${base}_prop_right`] = { label: 'Default right prop', type: 'text', value: metadata.prop_right || '', placeholder: 'minecraft:carrot', condition: fieldCondition, }; } function readActorAnimdefFormFields(result, prefix, actorIndex) { const base = `${prefix}_${actorIndex}`; return cleanActorAnimdefMetadata({ entity_types: parseCommaSeparatedStrings(result?.[`${base}_entity_types`]), entity_variant: result?.[`${base}_entity_variant`], actor_tags: parseCommaSeparatedStrings(result?.[`${base}_actor_tags`]), actor_tags_any: parseCommaSeparatedStrings(result?.[`${base}_actor_tags_any`]), activity: result?.[`${base}_activity`], injector: result?.[`${base}_injector`], receiver: !!result?.[`${base}_receiver`], prop_left: result?.[`${base}_prop_left`], prop_right: result?.[`${base}_prop_right`], }); } function validateActorAnimdefMetadata(metadata, actorLabel) { for (const entityType of metadata.entity_types || []) { if (!RESOURCE_ID_PATTERN.test(entityType)) { throw new Error(`Actor "${actorLabel}" has invalid entity type "${entityType}". Use namespace:path.`); } } if (metadata.entity_variant && !/^[a-z0-9_]+$/.test(metadata.entity_variant)) { throw new Error(`Actor "${actorLabel}" has invalid entity variant "${metadata.entity_variant}".`); } for (const [key, value] of [ ['left prop', metadata.prop_left], ['right prop', metadata.prop_right], ]) { if (value && !RESOURCE_ID_PATTERN.test(value)) { throw new Error(`Actor "${actorLabel}" has invalid ${key} item ID "${value}". Use namespace:path.`); } } if (metadata.injector && metadata.receiver) { throw new Error(`Actor "${actorLabel}" cannot be both injector ${metadata.injector} and an explicit receiver.`); } } function getSortedUsedActorIndicesFromProject() { return Array.from(getUsedActorIndicesFromProject()).sort((a, b) => a - b); } const convertPos = (p) => { const x = p?.[0] ?? 0, y = p?.[1] ?? 0, z = p?.[2] ?? 0; return [-x, y, z]; }; const convertRot = (r) => { const x = r?.[0] ?? 0, y = r?.[1] ?? 0, z = r?.[2] ?? 0; return [-x, -y, z]; }; function readActorIndexFromName(name) { if (typeof name !== 'string' || !name.startsWith('actor')) return null; let i = 5; // after 'actor' let digits = ''; while (i < name.length) { const c = name[i]; if (c < '0' || c > '9') break; digits += c; i++; } if (!digits || name[i] !== '_') return null; const idx = parseInt(digits, 10); return Number.isFinite(idx) && idx > 0 ? idx : null; } function getUsedActorIndicesFromProject() { const used = new Set(); for (const g of Group.all) { const idx = readActorIndexFromName(g?.name || ''); if (idx) used.add(idx); } return used; } function getNextActorIndex() { const used = getUsedActorIndicesFromProject(); let i = 1; while (used.has(i)) i++; return i; } function getMaxActorIndexFromProject() { let max = 0; for (const g of Group.all) { const idx = readActorIndexFromName(g?.name || ''); if (idx && idx > max) max = idx; } return max; } function extractGeckoGeometryRoot(json) { return json?.['minecraft:geometry']?.[0] || null; } function applyPerFaceUVWithXFlip(cube, uvObj) { if (!cube?.faces || !uvObj || typeof uvObj !== 'object') return; const uv = { ...uvObj }; const tmp = uv.east; uv.east = uv.west; uv.west = tmp; const faces = ['north', 'south', 'east', 'west', 'up', 'down']; for (const face of faces) { const f = uv[face]; if (!f) continue; const a = Array.isArray(f.uv) ? f.uv : null; const s = Array.isArray(f.uv_size) ? f.uv_size : null; if (!a || !s) continue; const u1 = num(a[0], 0); const v1 = num(a[1], 0); const w = num(s[0], 0); const h = num(s[1], 0); if (cube.faces[face]) cube.faces[face].uv = [u1, v1, u1 + w, v1 + h]; } cube.updateUV?.(); } // ---------------------------- // Import Actor Geometry // ---------------------------- function importGeometryIntoCurrentProject(geo, actorIndex) { const bones = Array.isArray(geo?.bones) ? geo.bones : []; if (!bones.length) { Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500); return; } const prefix = `actor${actorIndex}_`; Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], animations: [] }); const boneByName = new Map(); for (const b of bones) if (b?.name) boneByName.set(b.name, b); const groupByBoneName = new Map(); for (const boneName of boneByName.keys()) { const g = new Group({ name: prefix + boneName }); g.init(); groupByBoneName.set(boneName, g); } // parenting for (const [boneName, b] of boneByName) { const g = groupByBoneName.get(boneName); const parentGroup = b?.parent ? groupByBoneName.get(b.parent) : null; g.addTo(parentGroup || 'root'); } // pivots + rotations for (const [boneName, b] of boneByName) { const g = groupByBoneName.get(boneName); g.origin = convertPos(v3(b.pivot, 0)); g.rotation = convertRot(v3(b.rotation, 0)); } // cubes for (const [boneName, b] of boneByName) { const g = groupByBoneName.get(boneName); const cubes = Array.isArray(b?.cubes) ? b.cubes : []; for (let i = 0; i < cubes.length; i++) { const c = cubes[i]; const origin = v3(c?.origin, 0); const size = v3(c?.size, 0); const p1 = convertPos(origin); const p2 = convertPos([origin[0] + size[0], origin[1] + size[1], origin[2] + size[2]]); const from = [Math.min(p1[0], p2[0]), Math.min(p1[1], p2[1]), Math.min(p1[2], p2[2])]; const to = [Math.max(p1[0], p2[0]), Math.max(p1[1], p2[1]), Math.max(p1[2], p2[2])]; const cube = new Cube({ name: `${prefix}${boneName}_cube${i + 1}`, from, to, inflate: num(c?.inflate, 0), }); cube.init(); cube.addTo(g); if (c?.pivot) cube.origin = convertPos(v3(c.pivot, 0)); if (c?.rotation) cube.rotation = convertRot(v3(c.rotation, 0)); if (Array.isArray(c?.uv)) { cube.box_uv = true; cube.uv_offset = [num(c.uv[0], 0), num(c.uv[1], 0)]; } else if (c?.uv && typeof c.uv === 'object') { cube.box_uv = false; applyPerFaceUVWithXFlip(cube, c.uv); } } } Canvas.updateAll(); applyAllActorTexturePreviews(false); Undo.finishEdit(`Import Actor ${actorIndex}`); Blockbench.showQuickMessage(`Imported actor with prefix "${prefix}"`, 2500); } function importActorGeckoJson() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); return; } Filesystem.importFile( { type: 'GeckoLib / Bedrock Geometry JSON', extensions: ['json', 'geo.json'], multiple: false, readtype: 'text', resource_id: 'model', title: 'Import Actor (.json / .geo.json) with actorN_ prefix', }, (files) => { if (!files?.length) return; try { const json = JSON.parse(files[0]?.content || ''); const geo = extractGeckoGeometryRoot(json); if (!geo) { Blockbench.showQuickMessage('Unsupported JSON structure (expected minecraft:geometry[0]).', 3000); return; } importGeometryIntoCurrentProject(geo, getNextActorIndex()); } catch (e) { console.error(e); Blockbench.showQuickMessage(`Import failed: ${e?.message || e}`, 4000); } } ); } // ---------------------------- // Actor Preferences // ---------------------------- function editActorPreferences() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); return; } const actorIndices = getSortedUsedActorIndicesFromProject(); if (!actorIndices.length) { Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500); return; } const names = getProjectActorNames(); const textures = getProjectActorTextures(); const animdefMetadata = getProjectActorAnimdefMetadata(); const boneTextureOverrides = getProjectBoneTextureOverrides(); const textureOverrideGroups = getGroupsForTextureOverrides(); const loadedTextureNames = (Array.isArray(Texture.all) ? Texture.all : []) .map((t) => getTextureDisplayName(t)) .filter((name) => name); const textureOptions = buildTextureSelectOptions(textures, boneTextureOverrides); let showBoneTextureOverrides = false; let showAnimdefMetadata = false; const form = { _info: { type: 'info', text: 'Set export names, actor preview textures, animation-definition metadata, and folder texture overrides for this project. Folder overrides apply recursively and take priority over actor textures. Loaded textures: ' + (loadedTextureNames.length ? loadedTextureNames.join(', ') : '(none)'), }, }; for (const idx of actorIndices) { form[`name_${idx}`] = { label: `actor${idx} export name`, type: 'text', value: names[String(idx)] || '', placeholder: `actor${idx}`, }; form[`texture_${idx}`] = { label: `actor${idx} preview texture`, type: 'select', options: textureOptions, value: textures[String(idx)] || '', }; } form.show_animdef_metadata = { label: 'Animation definition metadata', type: 'checkbox', style: 'toggle_switch', value: showAnimdefMetadata, description: 'Show persistent AFW actor constraints used by animation-definition stub exports.', }; form._animdef_info = { type: 'info', text: 'AFW actor metadata', condition: (result) => result?.show_animdef_metadata ?? showAnimdefMetadata, }; actorIndices.forEach((idx, actorPosition) => { const label = names[String(idx)] || `actor${idx}`; const metadata = actorAnimdefMetadataWithInference(idx, animdefMetadata[String(idx)]); addActorAnimdefFormFields( form, 'preferences_animdef', idx, label, metadata, actorPosition === 0 ? 'active' : 'passive', (result) => result?.show_animdef_metadata ?? showAnimdefMetadata ); }); if (textureOverrideGroups.length) { form.show_bone_texture_overrides = { label: 'Folder texture overrides', type: 'checkbox', style: 'toggle_switch', value: showBoneTextureOverrides, description: 'Show recursive folder-level texture overrides. These take priority over actor textures.', }; form._bone_info = { type: 'info', text: 'Folder texture overrides', condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides, }; } for (let i = 0; i < textureOverrideGroups.length; i++) { const group = textureOverrideGroups[i]; const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides); form[`bone_texture_${i}`] = { label: getGroupTextureOverrideLabel(group), type: 'select', options: textureOptions, value: saved?.texture || '', condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides, }; } const dialog = new Dialog({ id: 'edit_actor_preferences', title: 'Actor Preferences', width: 720, form, buttons: ['Cancel', 'Save & Apply'], cancelIndex: 0, confirmIndex: 1, onFormChange: (result) => { showBoneTextureOverrides = !!result?.show_bone_texture_overrides; showAnimdefMetadata = !!result?.show_animdef_metadata; }, onConfirm: (result) => { try { const outNames = {}; const outTextures = {}; const outAnimdefMetadata = {}; const outBoneTextureOverrides = {}; for (const idx of actorIndices) { const name = String(result?.[`name_${idx}`] || '').trim(); const texture = String(result?.[`texture_${idx}`] || '').trim(); if (name) outNames[String(idx)] = name; if (texture) outTextures[String(idx)] = texture; const metadata = result?.show_animdef_metadata ? readActorAnimdefFormFields(result, 'preferences_animdef', idx) : cleanActorAnimdefMetadata(animdefMetadata[String(idx)]); validateActorAnimdefMetadata(metadata, name || `actor${idx}`); if (Object.keys(metadata).length) outAnimdefMetadata[String(idx)] = metadata; } for (let i = 0; i < textureOverrideGroups.length; i++) { const group = textureOverrideGroups[i]; const key = getGroupTextureOverrideKey(group); const resultKey = `bone_texture_${i}`; const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides); const rawTexture = Object.prototype.hasOwnProperty.call(result || {}, resultKey) ? result[resultKey] : saved?.texture; const texture = String(rawTexture || '').trim(); if (texture) { outBoneTextureOverrides[key] = { uuid: String(group.uuid || '').trim(), name: String(group.name || '').trim(), texture, }; } } setProjectActorNames(outNames); setProjectActorTextures(outTextures); setProjectActorAnimdefMetadata(outAnimdefMetadata); setProjectBoneTextureOverrides(outBoneTextureOverrides); applyAllActorTexturePreviews(true); return true; } catch (e) { Blockbench.showMessageBox({ title: 'Actor Preferences', message: e?.message || String(e), buttons: ['OK'], }); return false; } }, }); dialog.show(); } function editBoneTextureOverrides() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); return; } const groups = getGroupsForTextureOverrides(); if (!groups.length) { Blockbench.showQuickMessage('No folders found in this project.', 2500); return; } const boneTextureOverrides = getProjectBoneTextureOverrides(); const loadedTextureNames = (Array.isArray(Texture.all) ? Texture.all : []) .map((t) => getTextureDisplayName(t)) .filter((name) => name); const textureOptions = buildTextureSelectOptions({}, boneTextureOverrides); const form = { _info: { type: 'info', text: 'Assign preview/export texture overrides to individual folders. Overrides apply recursively to child folders and cubes. Loaded textures: ' + (loadedTextureNames.length ? loadedTextureNames.join(', ') : '(none)'), }, }; for (let i = 0; i < groups.length; i++) { const group = groups[i]; const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides); form[`bone_texture_${i}`] = { label: getGroupTextureOverrideLabel(group), type: 'select', options: textureOptions, value: saved?.texture || '', }; } const dialog = new Dialog({ id: 'edit_bone_texture_overrides', title: 'Bone Texture Overrides', width: 720, form, buttons: ['Cancel', 'Save & Apply'], cancelIndex: 0, confirmIndex: 1, onConfirm: (result) => { const out = {}; for (let i = 0; i < groups.length; i++) { const group = groups[i]; const key = getGroupTextureOverrideKey(group); const texture = String(result?.[`bone_texture_${i}`] || '').trim(); if (texture) { out[key] = { uuid: String(group.uuid || '').trim(), name: String(group.name || '').trim(), texture, }; } } setProjectBoneTextureOverrides(out); applyAllActorTexturePreviews(true); return true; }, }); dialog.show(); } // ---------------------------- // Actor Textures // ---------------------------- function getTextureDisplayName(texture) { return String(texture?.name || texture?.id || texture?.uuid || '').trim(); } function buildTextureSelectOptions(savedTextures, savedBoneTextureOverrides) { const options = { '': '(none)' }; const add = (name) => { const text = String(name || '').trim(); if (text && !Object.prototype.hasOwnProperty.call(options, text)) options[text] = text; }; const list = Array.isArray(Texture.all) ? Texture.all : []; for (const texture of list) add(getTextureDisplayName(texture)); for (const textureName of Object.values(savedTextures || {})) add(textureName); for (const override of Object.values(savedBoneTextureOverrides || {})) add(override?.texture); return options; } function findTextureByActorTextureName(name) { const wanted = String(name || '').trim(); if (!wanted) return null; const wantedLower = wanted.toLowerCase(); const list = Array.isArray(Texture.all) ? Texture.all : []; return ( list.find((t) => String(t?.name || '').trim().toLowerCase() === wantedLower) || list.find((t) => String(t?.id || '').trim().toLowerCase() === wantedLower) || list.find((t) => String(t?.uuid || '').trim().toLowerCase() === wantedLower) || list.find((t) => String(t?.name || '').trim().toLowerCase().replace(/\.[^.]+$/, '') === wantedLower) || null ); } function groupBelongsToActor(group, actorIndex) { const prefix = `actor${actorIndex}_`; let g = group; while (g) { if (typeof g?.name === 'string' && g.name.startsWith(prefix)) return true; g = g.parent instanceof Group ? g.parent : null; } return false; } function getGroupsForTextureOverrides() { const groups = Array.isArray(Group.all) ? Group.all.filter((group) => group) : []; return groups.sort((a, b) => String(a?.name || '').localeCompare(String(b?.name || ''))); } function getGroupTextureOverrideKey(group) { return String(group?.uuid || group?.name || '').trim(); } function getGroupTextureOverrideLabel(group) { return String(group?.name || '(unnamed folder)').trim(); } function getBoneTextureOverrideForGroup(group, overrides) { if (!group || !overrides) return null; const key = getGroupTextureOverrideKey(group); const direct = key ? overrides[key] : null; if (direct?.texture) return direct; const name = String(group.name || '').trim(); if (!name) return null; return Object.values(overrides).find((override) => override?.name === name && override?.texture) || null; } function cubeBelongsToActor(cube, actorIndex) { const prefix = `actor${actorIndex}_`; if (typeof cube?.name === 'string' && cube.name.startsWith(prefix)) return true; return groupBelongsToActor(cube?.parent, actorIndex); } function getTextureMaterial(texture) { if (!texture) return null; if (typeof texture.getMaterial === 'function') return texture.getMaterial(); if (typeof texture.getOwnMaterial === 'function') return texture.getOwnMaterial(); return texture.material || null; } function forceActorTextureMaterial(cube, texture) { const material = getTextureMaterial(texture); if (!cube?.mesh || !material) return false; cube.mesh.material = material; cube.mesh.material.needsUpdate = true; cube.mesh.needsUpdate = true; cube.mesh.userData = cube.mesh.userData || {}; cube.mesh.userData.multiactor_actor_texture_uuid = texture.uuid || texture.id || texture.name; return true; } function applyTextureToCube(cube, texture) { if (!cube || !texture) return; const faces = ['north', 'south', 'east', 'west', 'up', 'down']; const textureId = texture.uuid || texture.id || texture.name; if (textureId && cube.faces) { for (const face of faces) { const cubeFace = cube.faces[face]; if (!cubeFace) continue; cubeFace.texture = textureId; } } // In GeckoLib/single-texture formats, Blockbench can still render every cube with the selected/default texture. // So after updating the normal face data, force the actual Three.js mesh material for preview only. cube.preview_controller?.updateFaces?.(cube); cube.preview_controller?.updateUV?.(cube); cube.preview_controller?.updateGeometry?.(cube); forceActorTextureMaterial(cube, texture); // Some preview updates are deferred internally, so apply the material again on the next tick. setTimeout(() => forceActorTextureMaterial(cube, texture), 0); } function applyActorTexturePreview(actorIndex, textureName) { const texture = findTextureByActorTextureName(textureName); if (!texture) return { applied: 0, missing: true }; let applied = 0; const cubes = Array.isArray(Cube.all) ? Cube.all : []; for (const cube of cubes) { if (!cubeBelongsToActor(cube, actorIndex)) continue; applyTextureToCube(cube, texture); applied++; } return { applied, missing: false }; } function getNearestBoneTextureOverride(cube, overrides) { let group = cube?.parent instanceof Group ? cube.parent : null; while (group) { const override = getBoneTextureOverrideForGroup(group, overrides); if (override?.texture) return override; group = group.parent instanceof Group ? group.parent : null; } return null; } function applyBoneTextureOverrides() { const overrides = getProjectBoneTextureOverrides(); const missing = []; let applied = 0; if (!Object.keys(overrides).length) return { applied, missing }; const textureByName = {}; const cubes = Array.isArray(Cube.all) ? Cube.all : []; for (const cube of cubes) { const override = getNearestBoneTextureOverride(cube, overrides); const textureName = String(override?.texture || '').trim(); if (!textureName) continue; if (!Object.prototype.hasOwnProperty.call(textureByName, textureName)) { textureByName[textureName] = findTextureByActorTextureName(textureName); } const texture = textureByName[textureName]; if (!texture) { if (!missing.includes(textureName)) missing.push(textureName); continue; } applyTextureToCube(cube, texture); applied++; } return { applied, missing }; } function applyAllActorTexturePreviews(showMessage = false) { const textures = getProjectActorTextures(); const missing = []; let applied = 0; for (const [idxText, textureName] of Object.entries(textures)) { const idx = parseInt(idxText, 10); if (!Number.isFinite(idx) || idx <= 0) continue; const result = applyActorTexturePreview(idx, textureName); applied += result.applied || 0; if (result.missing) missing.push(textureName); } const boneResult = applyBoneTextureOverrides(); applied += boneResult.applied || 0; for (const textureName of boneResult.missing || []) { if (!missing.includes(textureName)) missing.push(textureName); } Canvas.updateAll?.(); if (showMessage) { if (missing.length) { const missingMessage = 'Applied texture preferences where possible. These textures are not loaded in this project:' + '\n\n' + missing.map((x) => `- ${x}`).join('\n'); Blockbench.showMessageBox({ title: 'Actor Preferences', message: missingMessage, buttons: ['OK'], }); } else { Blockbench.showQuickMessage(`Applied texture preferences to ${applied} cubes`, 2500); } } } function clearScheduledActorTexturePreviewApplies() { for (const timeout of actorTexturePreviewTimeouts) clearTimeout(timeout); actorTexturePreviewTimeouts = []; } function hasProjectActorTexturePreviews() { return Object.keys(getProjectActorTextures()).length > 0 || Object.keys(getProjectBoneTextureOverrides()).length > 0; } function scheduleActorTexturePreviewApply() { clearScheduledActorTexturePreviewApplies(); if (!Project || !hasProjectActorTexturePreviews()) return; // Project loading can rebuild preview meshes and load textures after project events fire. // Reapply for a short window so the saved actor texture preview wins over the selected texture. for (const delay of [0, 50, 250, 1000, 2500, 5000, 10000]) { const timeout = setTimeout(() => { if (Project && hasProjectActorTexturePreviews()) applyAllActorTexturePreviews(false); }, delay); actorTexturePreviewTimeouts.push(timeout); } } // ---------------------------- // Export Models // ---------------------------- function getTextureExportPath(textureName) { const saved = String(textureName || '').trim(); if (!saved) return ''; if (saved.includes(':')) return saved.replace(/\\/g, '/'); const texture = findTextureByActorTextureName(saved); if (texture) { const path = String(texture.path || texture.relative_path || '').replace(/\\/g, '/'); if (path) { const assetsMatch = path.match(/(?:^|\/)assets\/([^/]+)\/(.+)$/i); if (assetsMatch) return `${assetsMatch[1]}:${assetsMatch[2]}`; const texturesIndex = path.toLowerCase().lastIndexOf('/textures/'); if (texturesIndex !== -1) { return `minecraft:${path.slice(texturesIndex + 1)}`; } } if (typeof texture.javaTextureLink === 'function') { const link = String(texture.javaTextureLink() || '').trim(); if (link) { const normalized = link.replace(/\\/g, '/'); return /\.[a-z0-9]+$/i.test(normalized) ? normalized : `${normalized}.png`; } } } return saved.replace(/\\/g, '/'); } function getTextureExportPathFromTexture(texture) { if (!texture) return ''; return getTextureExportPath(texture.name || texture.id || texture.uuid); } function findTextureById(textureId) { const wanted = String(textureId || '').trim(); if (!wanted) return null; const wantedLower = wanted.toLowerCase(); const list = Array.isArray(Texture.all) ? Texture.all : []; return ( list.find((t) => String(t?.uuid || '').trim().toLowerCase() === wantedLower) || list.find((t) => String(t?.id || '').trim().toLowerCase() === wantedLower) || list.find((t) => String(t?.name || '').trim().toLowerCase() === wantedLower) || null ); } function getCubeReadableTexture(cube) { const forcedTextureId = cube?.mesh?.userData?.multiactor_actor_texture_uuid; const forcedTexture = findTextureById(forcedTextureId); if (forcedTexture) return forcedTexture; const faces = ['north', 'south', 'east', 'west', 'up', 'down']; for (const face of faces) { const textureId = cube?.faces?.[face]?.texture; const texture = findTextureById(textureId); if (texture) return texture; } return null; } function getReadableTexturePathFromCube(cube) { return getTextureExportPathFromTexture(getCubeReadableTexture(cube)); } function findGroupForTextureOverride(overrideKey, override) { const groups = Array.isArray(Group.all) ? Group.all : []; const uuid = String(override?.uuid || overrideKey || '').trim(); const name = String(override?.name || '').trim(); return ( groups.find((group) => String(group?.uuid || '').trim() === uuid) || groups.find((group) => String(group?.name || '').trim() === name) || null ); } function buildAfwBoneTextureMap() { const overrides = getProjectBoneTextureOverrides(); const out = {}; const explicitBones = new Set(); for (const [key, override] of Object.entries(overrides)) { const group = findGroupForTextureOverride(key, override); const boneName = String(group?.name || override?.name || '').trim(); const texturePath = getTextureExportPath(override?.texture); if (boneName && texturePath) { out[boneName] = texturePath; explicitBones.add(boneName); } } const cubes = Array.isArray(Cube.all) ? Cube.all : []; const textureCounts = {}; const boneTextureCounts = {}; for (const cube of cubes) { const group = cube?.parent instanceof Group ? cube.parent : null; const boneName = String(group?.name || '').trim(); const texturePath = getReadableTexturePathFromCube(cube); if (!boneName || !texturePath) continue; textureCounts[texturePath] = (textureCounts[texturePath] || 0) + 1; boneTextureCounts[boneName] = boneTextureCounts[boneName] || {}; boneTextureCounts[boneName][texturePath] = (boneTextureCounts[boneName][texturePath] || 0) + 1; } let baseTexture = ''; let baseCount = 0; for (const [texturePath, count] of Object.entries(textureCounts)) { if (count > baseCount) { baseTexture = texturePath; baseCount = count; } } for (const [boneName, counts] of Object.entries(boneTextureCounts)) { if (explicitBones.has(boneName)) continue; let dominantTexture = ''; let dominantCount = 0; for (const [texturePath, count] of Object.entries(counts)) { if (count > dominantCount) { dominantTexture = texturePath; dominantCount = count; } } if (dominantTexture && dominantTexture !== baseTexture) out[boneName] = dominantTexture; } return out; } function addAfwBoneTextureMapToGeckoJson(json, afwBoneTextures) { if (!Object.keys(afwBoneTextures || {}).length) return json; const output = {}; output.afw_bone_textures = afwBoneTextures; for (const [key, value] of Object.entries(json || {})) { if (key !== 'afw_bone_textures') output[key] = value; } return output; } function stringifyCompactModelJson(obj, indent = 2) { let i = 0; const tokenPrefix = '__COMPACT_MODEL_JSON__'; const replacements = new Map(); const isNumericArray = (value) => Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === 'number' && Number.isFinite(n)); const isCompactCube = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const keys = Object.keys(value); if (!keys.length) return false; const compactKeys = new Set(['origin', 'size', 'pivot', 'rotation', 'uv', 'inflate', 'mirror']); for (const key of keys) { const child = value[key]; if (!compactKeys.has(key)) return false; if (Array.isArray(child)) { if (!isNumericArray(child)) return false; } else if (typeof child !== 'number' && typeof child !== 'boolean') { return false; } } return Array.isArray(value.origin) && Array.isArray(value.size); }; const isCompactUvFace = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const keys = Object.keys(value); if (!keys.length || keys.length > 3) return false; if (!Array.isArray(value.uv) || !Array.isArray(value.uv_size)) return false; for (const key of keys) { if (key === 'uv' || key === 'uv_size') { if (!isNumericArray(value[key])) return false; } else if (key !== 'texture') { return false; } } return true; }; const inline = (value) => { if (isNumericArray(value)) return `[${value.join(', ')}]`; const parts = []; for (const [key, child] of Object.entries(value)) { const childText = isNumericArray(child) ? `[${child.join(', ')}]` : JSON.stringify(child); parts.push(`${JSON.stringify(key)}: ${childText}`); } return `{${parts.join(', ')}}`; }; const json = JSON.stringify( obj, (key, value) => { if (isCompactCube(value) || isCompactUvFace(value) || isNumericArray(value)) { const token = `${tokenPrefix}${i++}__`; replacements.set(token, inline(value)); return token; } return value; }, indent ); let out = json; for (const [token, text] of replacements) out = out.split(`"${token}"`).join(text); return out; } function getGeckoModelCodecCandidates() { const candidates = []; const add = (codec) => { if (codec && typeof codec.compile === 'function' && !candidates.includes(codec)) candidates.push(codec); }; add(Format?.codec); add(Codecs?.bedrock); add(Codecs?.bedrock_old); if (Codecs && typeof Codecs === 'object') { for (const codec of Object.values(Codecs)) { const name = String(codec?.name || codec?.id || '').toLowerCase(); if (name.includes('gecko') || name.includes('bedrock')) add(codec); } } return candidates; } function parseCompiledModelContent(content) { if (typeof content === 'string') return JSON.parse(content); if (content && typeof content === 'object') return clone(content); throw new Error('Model compiler returned empty content.'); } function compileGeckoModelJson() { const candidates = getGeckoModelCodecCandidates(); let lastError = null; for (const codec of candidates) { try { const options = typeof codec.getExportOptions === 'function' ? codec.getExportOptions() : undefined; const json = parseCompiledModelContent(codec.compile(options)); if (Array.isArray(json?.['minecraft:geometry'])) return json; } catch (e) { lastError = e; } } throw new Error(lastError?.message || 'Could not find a GeckoLib/Bedrock model compiler for this project.'); } function getModelExportFileName() { const codec = Format?.codec; const codecName = typeof codec?.fileName === 'function' ? codec.fileName() : ''; const name = codecName || Project?.geometry_name || Project?.name || 'model'; return sanitizeFilePart(String(name).replace(/\.geo\.json$/i, '').replace(/\.json$/i, '')) || 'model'; } function exportGeckoModel() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); return; } try { const compiled = compileGeckoModelJson(); const afwBoneTextures = buildAfwBoneTextureMap(); const output = addAfwBoneTextureMapToGeckoJson(compiled, afwBoneTextures); Blockbench.export( { type: 'GeckoLib Model JSON', extensions: ['json', 'geo.json'], name: `${getModelExportFileName()}.geo`, content: stringifyCompactModelJson(output, 2), savetype: 'text', }, () => Blockbench.showQuickMessage('Exported GeckoLib model', 2500) ); } catch (e) { console.error(e); Blockbench.showMessageBox({ title: 'Export GeckoLib Model', message: `Export failed: ${e?.message || e}`, buttons: ['OK'], }); } } // ---------------------------- // Export Actor Animations // ---------------------------- function getSelectedAnimation() { return Animation.selected || Timeline.animation || Animation.all?.[0] || null; } function stripPrefixFromAnimationFileForActor(fileObj, actorIndex) { const prefix = `actor${actorIndex}_`; const anims = fileObj?.animations; if (!anims) return; for (const animKey of Object.keys(anims)) { const anim = anims[animKey]; if (!anim) continue; if (anim.bones) { const newBones = {}; for (const [boneName, payload] of Object.entries(anim.bones)) { if (boneName.startsWith(prefix)) newBones[boneName.slice(prefix.length)] = payload; } anim.bones = newBones; } // De-prefix common string fields (locators/particles) const fixStrings = (node) => { if (!node || typeof node !== 'object') return; for (const k of Object.keys(node)) { const v = node[k]; if (typeof v === 'string') { if ((k === 'bone' || k === 'locator') && v.startsWith(prefix)) node[k] = v.slice(prefix.length); } else if (v && typeof v === 'object') { fixStrings(v); } } }; fixStrings(anim); } } function stringifyInlineVec3(obj, indent = 2) { let i = 0; const tokenPrefix = '__INLINE_VEC3__'; const replacements = new Map(); const json = JSON.stringify( obj, (key, value) => { if (Array.isArray(value) && value.length === 3 && value.every((n) => typeof n === 'number' && Number.isFinite(n))) { const token = `${tokenPrefix}${i++}__`; replacements.set(token, `[${value[0]},${value[1]},${value[2]}]`); return token; } return value; }, indent ); let out = json; for (const [token, vecStr] of replacements) out = out.split(`"${token}"`).join(vecStr); return out; } function buildActorAnimationOutputsForAnimation(anim, maxActor) { if (typeof anim?.compileBedrockAnimation !== 'function') { throw new Error(`Animation compiler not available for "${anim?.name || 'animation'}" (missing compileBedrockAnimation).`); } const animKey = anim.name || 'animation.unnamed'; const safeAnim = sanitizeFilePart(animKey); const compiled = anim.compileBedrockAnimation() || {}; const baseFile = compiled.animations ? compiled : { format_version: '1.8.0', animations: { [animKey]: compiled } }; const outputs = []; for (let i = 1; i <= maxActor; i++) { const f = clone(baseFile); stripPrefixFromAnimationFileForActor(f, i); const safeActor = sanitizeFilePart(getActorExportLabel(i)) || `actor${i}`; outputs.push({ name: `${safeAnim}_${safeActor}.animation`, content: stringifyInlineVec3(f, 2), }); } return outputs; } function exportActorAnimationOutputs(outputs, doneMessage) { let idx = 0; const next = () => { if (idx >= outputs.length) { Blockbench.showQuickMessage(doneMessage || `Exported ${outputs.length} actor animation files`, 2500); return; } const o = outputs[idx++]; Blockbench.export( { type: 'JSON', extensions: ['json'], name: o.name, content: o.content, savetype: 'text', }, next ); }; next(); } function exportSelectedActorAnimation() { const anim = getSelectedAnimation(); if (!anim) { Blockbench.showQuickMessage('No animation selected.', 2500); return; } const maxActor = getMaxActorIndexFromProject(); if (maxActor < 1) { Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500); return; } try { const outputs = buildActorAnimationOutputsForAnimation(anim, maxActor); exportActorAnimationOutputs(outputs, `Exported ${outputs.length} actor animation files`); } catch (e) { console.error(e); Blockbench.showMessageBox({ title: 'Export Selected Animations (Split Files)', message: `Export failed: ${e?.message || e}`, buttons: ['OK'], }); } } function exportAllActorAnimations() { const animations = Array.isArray(Animation.all) ? Animation.all.filter((a) => a) : []; if (!animations.length) { Blockbench.showQuickMessage('No animations found.', 2500); return; } const maxActor = getMaxActorIndexFromProject(); if (maxActor < 1) { Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500); return; } try { const outputs = []; for (const anim of animations) outputs.push(...buildActorAnimationOutputsForAnimation(anim, maxActor)); exportActorAnimationOutputs(outputs, `Exported ${outputs.length} actor animation files from ${animations.length} animations`); } catch (e) { console.error(e); Blockbench.showMessageBox({ title: 'Export All Animations (Split Files)', message: `Export failed: ${e?.message || e}`, buttons: ['OK'], }); } } function parseAfwStageAnimationName(animation) { const name = String(animation?.name || '').trim(); const match = /^([a-z0-9._-]+)\.p(\d+)$/.exec(name); if (!match) return null; return { id: match[1], stageNumber: Number(match[2]), name, animation, }; } function collectConjoinedAnimationGroups() { const groupsById = new Map(); const ignored = []; const animations = Array.isArray(Animation.all) ? Animation.all.filter((animation) => animation) : []; for (const animation of animations) { const stage = parseAfwStageAnimationName(animation); if (!stage) { ignored.push(String(animation?.name || 'unnamed')); continue; } if (!groupsById.has(stage.id)) groupsById.set(stage.id, []); groupsById.get(stage.id).push(stage); } const groups = Array.from(groupsById, ([id, stages]) => { stages.sort((a, b) => a.stageNumber - b.stageNumber || a.name.localeCompare(b.name)); const seenStages = new Set(); for (const stage of stages) { if (seenStages.has(stage.stageNumber)) { throw new Error(`Animation "${id}" contains more than one .p${stage.stageNumber} stage.`); } seenStages.add(stage.stageNumber); } return { id, stages }; }); groups.sort((a, b) => a.id.localeCompare(b.id)); return { groups, ignored }; } function compileConjoinedStage(stage) { const animation = stage?.animation; if (typeof animation?.compileBedrockAnimation !== 'function') { throw new Error(`Animation compiler not available for "${stage?.name || 'animation'}".`); } const compiled = animation.compileBedrockAnimation() || {}; if (!compiled.animations) { return { formatVersion: '1.8.0', clip: clone(compiled) }; } let clip = compiled.animations[stage.name]; if (!clip) { const entries = Object.entries(compiled.animations); if (entries.length !== 1) { throw new Error(`Compiled animation "${stage.name}" did not contain a matching animation key.`); } clip = entries[0][1]; } return { formatVersion: compiled.format_version || '1.8.0', clip: clone(clip), }; } function getActorIndicesFromAnimationClip(clip) { const actors = new Set(); for (const boneName of Object.keys(clip?.bones || {})) { const actorIndex = readActorIndexFromName(boneName); if (actorIndex) actors.add(actorIndex); } return actors; } function validateConjoinedActorLabels(actorIndices) { const labels = new Map(); const used = new Set(); for (const actorIndex of actorIndices) { const label = String(getActorExportLabel(actorIndex) || '').trim(); if (!/^[a-z0-9._-]+$/.test(label)) { throw new Error(`Actor ${actorIndex} export name "${label}" must use only lowercase letters, numbers, ., _ or -.`); } if (used.has(label)) { throw new Error(`Actor export name "${label}" is assigned to more than one actor.`); } labels.set(actorIndex, label); used.add(label); } return labels; } function collectParticleOwnerIndices(node, owners, fieldName = '') { if (node == null) return; if (typeof node === 'string') { if (fieldName === 'locator' || fieldName === 'bone') { const actorIndex = readActorIndexFromName(node); if (actorIndex) owners.add(actorIndex); } return; } if (typeof node !== 'object') return; if (Array.isArray(node)) { for (const value of node) collectParticleOwnerIndices(value, owners, fieldName); return; } for (const [key, value] of Object.entries(node)) { collectParticleOwnerIndices(value, owners, key); } } function particleOwnerForEvent(event, stageName, cueTime) { const owners = new Set(); collectParticleOwnerIndices(event, owners); if (owners.size !== 1) { const detail = owners.size === 0 ? 'no actor-prefixed locator' : 'locators from multiple actors'; throw new Error(`Particle cue ${stageName}@${cueTime} has ${detail}. Use one actorN_ locator per cue.`); } return owners.values().next().value; } function filterParticleEffectsForActor(clip, actorIndex, stageName) { if (!clip?.particle_effects || typeof clip.particle_effects !== 'object') return; const filtered = {}; for (const [cueTime, payload] of Object.entries(clip.particle_effects)) { const events = Array.isArray(payload) ? payload : [payload]; const kept = events.filter((event) => particleOwnerForEvent(event, stageName, cueTime) === actorIndex); if (!kept.length) continue; filtered[cueTime] = Array.isArray(payload) ? kept : kept[0]; } if (Object.keys(filtered).length) clip.particle_effects = filtered; else delete clip.particle_effects; } function buildConjoinedAnimationOutput(group) { const compiledStages = group.stages.map((stage) => ({ ...stage, ...compileConjoinedStage(stage) })); const actorSet = new Set(); for (const stage of compiledStages) { for (const actorIndex of getActorIndicesFromAnimationClip(stage.clip)) actorSet.add(actorIndex); } const actorIndices = Array.from(actorSet).sort((a, b) => a - b); if (!actorIndices.length) { throw new Error(`Animation "${group.id}" has no animated actorN_ bones.`); } const actorLabels = validateConjoinedActorLabels(actorIndices); const soundOwner = actorIndices[0]; const output = { format_version: compiledStages[0]?.formatVersion || '1.8.0', animations: {}, }; for (const stage of compiledStages) { for (const actorIndex of actorIndices) { const clip = clone(stage.clip); filterParticleEffectsForActor(clip, actorIndex, stage.name); if (actorIndex !== soundOwner) delete clip.sound_effects; const wrapped = { animations: { clip } }; stripPrefixFromAnimationFileForActor(wrapped, actorIndex); output.animations[`p${stage.stageNumber}_${actorLabels.get(actorIndex)}`] = wrapped.animations.clip; } } return { name: `${sanitizeFilePart(group.id)}.animation`, content: stringifyInlineVec3(output, 2), }; } function exportSelectedConjoinedAnimationGroups(groups) { try { const outputs = groups.map(buildConjoinedAnimationOutput); exportActorAnimationOutputs( outputs, `Exported ${outputs.length} animation file${outputs.length === 1 ? '' : 's'}` ); } catch (e) { console.error(e); Blockbench.showMessageBox({ title: 'Export Animations', message: `Export failed: ${e?.message || e}`, buttons: ['OK'], }); } } function showConjoinedAnimationExportDialog() { let detected; try { detected = collectConjoinedAnimationGroups(); } catch (e) { Blockbench.showMessageBox({ title: 'Export Animations', message: e?.message || String(e), buttons: ['OK'], }); return; } if (!detected.groups.length) { Blockbench.showMessageBox({ title: 'Export Animations', message: 'No animations named .p were found in this project.', buttons: ['OK'], }); return; } const form = { _info: { type: 'info', text: 'Select the animation IDs to export. Each selected ID becomes one .animation.json file.', }, }; detected.groups.forEach((group, index) => { form[`animation_${index}`] = { label: `${group.id} (${group.stages.length} stage${group.stages.length === 1 ? '' : 's'})`, type: 'checkbox', value: true, }; }); if (detected.ignored.length) { form._ignored = { type: 'info', text: `Ignored animations without a valid .p name: ${detected.ignored.join(', ')}`, }; } const dialog = new Dialog({ id: 'export_conjoined_actor_animations', title: 'Export Animations', width: 680, form, buttons: ['Cancel', 'Export Selected'], cancelIndex: 0, confirmIndex: 1, onConfirm: (result) => { const selected = detected.groups.filter((group, index) => !!result?.[`animation_${index}`]); if (!selected.length) { Blockbench.showQuickMessage('No animation IDs selected.', 2500); return false; } exportSelectedConjoinedAnimationGroups(selected); return true; }, }); dialog.show(); } // ---------------------------- // Create AFW Animation Definition Stub // ---------------------------- function compactNumber(value) { return Number(Number(value).toFixed(6)); } function parseLoopMode(value) { if (value === true) return true; if (value === false) return false; const normalized = String(value ?? '').trim().toLowerCase(); if (normalized === 'loop' || normalized === 'true') return true; if (normalized === 'once' || normalized === 'hold' || normalized === 'hold_on_last_frame' || normalized === 'false') return false; return null; } function detectStageLoop(stage, clip) { const animationLoop = parseLoopMode(stage?.animation?.loop); if (animationLoop !== null) return animationLoop; const compiledLoop = parseLoopMode(clip?.loop); return compiledLoop === null ? false : compiledLoop; } function detectStageCycleSeconds(stage, clip) { for (const candidate of [clip?.animation_length, stage?.animation?.length]) { const value = Number(candidate); if (Number.isFinite(value) && value > 0) return compactNumber(value); } throw new Error(`Animation "${stage?.name || 'unknown'}" has no positive animation length.`); } function analyzeAnimationDefinitionGroup(group) { const actorSet = new Set(); const warnings = []; const stages = group.stages.map((stage) => { const compiled = compileConjoinedStage(stage); const stageActors = getActorIndicesFromAnimationClip(compiled.clip); for (const actorIndex of stageActors) actorSet.add(actorIndex); if (!stageActors.size) warnings.push(`${stage.name} has no animated actorN_ bones.`); return { stageNumber: stage.stageNumber, name: stage.name, loop: detectStageLoop(stage, compiled.clip), cycleSeconds: detectStageCycleSeconds(stage, compiled.clip), }; }); const actorIndices = Array.from(actorSet).sort((a, b) => a - b); if (!actorIndices.length) throw new Error(`Animation "${group.id}" has no animated actorN_ bones.`); const actorLabels = validateConjoinedActorLabels(actorIndices); for (let i = 0; i < stages.length; i++) { const expected = i + 1; if (stages[i].stageNumber !== expected) { warnings.push(`Stages are not contiguous: expected p${expected}, found p${stages[i].stageNumber}.`); break; } } return { actorIndices, actorLabels, stages, warnings }; } function parseRequiredPositiveNumber(value, label) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${label} must be a positive number.`); return compactNumber(parsed); } function parseOptionalNumber(value, label, validator) { const raw = String(value ?? '').trim(); if (!raw) return null; const parsed = Number(raw); if (!Number.isFinite(parsed)) throw new Error(`${label} must be a number.`); if (validator && !validator(parsed)) throw new Error(`${label} is outside its supported range.`); return compactNumber(parsed); } function buildActorConstraint(actorLabel, metadata) { validateActorAnimdefMetadata(metadata, actorLabel); const actor = { label: actorLabel }; if (metadata.entity_types?.length) actor.entity_types = metadata.entity_types; if (metadata.entity_variant) actor.entity_variant = metadata.entity_variant; if (metadata.actor_tags?.length) actor.actor_tags = metadata.actor_tags; if (metadata.actor_tags_any?.length) actor.actor_tags_any = metadata.actor_tags_any; if (metadata.activity) actor.activity = metadata.activity; if (metadata.injector) actor.injector = metadata.injector; if (metadata.receiver) actor.receiver = true; if (metadata.prop_left) actor.prop_left = metadata.prop_left; if (metadata.prop_right) actor.prop_right = metadata.prop_right; return actor; } function readStageDefinitionFromForm(result, stage, isLastStage) { const base = `stub_stage_${stage.stageNumber}`; const loop = !!result?.[`${base}_loop`]; const cycleSeconds = parseRequiredPositiveNumber(result?.[`${base}_cycle_seconds`], `Stage p${stage.stageNumber} cycle seconds`); const speed = parseOptionalNumber(result?.[`${base}_speed`], `Stage p${stage.stageNumber} speed`, (value) => value > 0); const midpointOffset = parseOptionalNumber( result?.[`${base}_midpoint_offset`], `Stage p${stage.stageNumber} midpoint offset`, () => loop ); const stageSeconds = parseOptionalNumber( result?.[`${base}_stage_seconds`], `Stage p${stage.stageNumber} stage seconds`, (value) => Number.isInteger(value) && (value === -1 || (value >= 1 && value <= 300)) ); const durationMultiplier = parseOptionalNumber( result?.[`${base}_duration_multiplier`], `Stage p${stage.stageNumber} duration multiplier`, (value) => loop && value >= 0.05 && value <= 20 ); const escapable = Object.prototype.hasOwnProperty.call(result || {}, `${base}_escapable`) ? !!result[`${base}_escapable`] : !isLastStage; const peaked = Object.prototype.hasOwnProperty.call(result || {}, `${base}_peaked`) ? !!result[`${base}_peaked`] : isLastStage; const output = { stage: stage.stageNumber, loop, cycle_seconds: cycleSeconds, allow_join: Object.prototype.hasOwnProperty.call(result || {}, `${base}_allow_join`) ? !!result[`${base}_allow_join`] : !isLastStage, }; if (midpointOffset !== null) output.cycle_midpoint_offset_seconds = midpointOffset; if (speed !== null) output.speed = speed; if (peaked) output.non_peak = true; output.escapable = escapable; if (stageSeconds !== null) output.stage_seconds = stageSeconds; if (durationMultiplier !== null) output.stage_duration_multiplier = durationMultiplier; return output; } function exportAnimationDefinitionStub(group, definition, actorMetadata) { const mergedMetadata = { ...getProjectActorAnimdefMetadata(), ...actorMetadata }; setProjectActorAnimdefMetadata(mergedMetadata); setProjectAnimdefAnimators(definition.animators || []); Blockbench.export( { type: 'AFW Animation Definition', extensions: ['json'], name: sanitizeFilePart(group.id), content: JSON.stringify(definition, null, 2), savetype: 'text', }, () => Blockbench.showQuickMessage( `Created ${group.id}.json for data//afw_animdefs/`, 3500 ) ); } function confirmUnresolvedActorTypes(unresolvedLabels, onContinue) { if (!unresolvedLabels.length) { onContinue(); return; } Blockbench.showMessageBox( { title: 'Unresolved Entity Types', message: `Could not infer entity types for: ${unresolvedLabels.join(', ')}.\n\n` + 'The stub will omit entity_types for these actors, which makes them wildcard constraints. Manually add the correct entity_types before using this animation definition.', buttons: ['Cancel', 'Export Stub'], }, (button) => { if (button === 1 || button === 'Export Stub') onContinue(); } ); } function showAnimationDefinitionReviewDialog(group, analysis) { const savedMetadata = getProjectActorAnimdefMetadata(); const form = { _info: { type: 'info', text: `Creating ${group.id}.json with ${analysis.actorIndices.length} actor(s) and ${analysis.stages.length} stage(s). ` + 'Save it under data//afw_animdefs/. Actor metadata is stored in this Blockbench project for later exports.', }, }; if (analysis.warnings.length) { form._warnings = { type: 'info', text: `Warnings: ${analysis.warnings.join(' ')}`, }; } form.display_name = { label: 'Display Name', type: 'text', value: '', placeholder: group.id, description: 'Optional player-facing name. AFW uses the animation ID when this is empty.', }; form.animators = { label: 'Animators', type: 'text', value: getProjectAnimdefAnimators().join(', '), placeholder: 'Animator One, Animator Two', description: 'Optional comma-separated individual animation credits. Saved in this Blockbench project.', }; const isTease = (result) => result?.stub_behavior === 'tease'; const isStuck = (result) => result?.stub_behavior === 'stuck'; form._behavior = { type: 'info', text: 'Specialized NoN behavior' }; form.stub_behavior = { label: 'Behavior', type: 'select', options: { standard: 'Standard animation', tease: 'Tease animation', stuck: 'Stuck animation', }, value: 'standard', description: 'Adds the corresponding system tag and metadata object to the stub.', }; form.stub_tease_multiplier = { label: 'Nearby energy multiplier', type: 'number', value: 2, min: 1, max: 20, step: 0.1, condition: isTease, }; form.stub_stuck_content_tags = { label: 'Matching content tags', type: 'text', value: '', placeholder: 'stuckfence, restrained', description: 'Comma-separated exact tags used to match the preceding attack.', condition: isStuck, }; form.stub_stuck_initial_difficulty = { label: 'Initial escape difficulty', type: 'number', value: 2, min: 0.1, max: 10, step: 0.05, condition: isStuck, }; form.stub_stuck_final_difficulty = { label: 'Final escape difficulty', type: 'number', value: 0.75, min: 0.1, max: 10, step: 0.05, condition: isStuck, }; form.stub_stuck_ease_delay = { label: 'Ease delay seconds', type: 'number', value: 10, min: 0, max: 600, step: 1, condition: isStuck, }; form.stub_stuck_ease_duration = { label: 'Ease duration seconds', type: 'number', value: 30, min: 0, max: 600, step: 1, condition: isStuck, }; form.stub_stuck_joined_difficulty = { label: 'Joined escape difficulty', type: 'number', value: 1.5, min: 0.1, max: 10, step: 0.05, condition: isStuck, }; form.stub_stuck_damage_behavior = { label: 'Damage behavior', type: 'select', options: { stop_on_damage: 'Stop animation on damage', ignore_damage: 'Apply damage without stopping', block_damage: 'Prevent damage', }, value: 'stop_on_damage', condition: isStuck, }; form._actors = { type: 'info', text: 'Actor constraints' }; analysis.actorIndices.forEach((actorIndex, actorPosition) => { const label = analysis.actorLabels.get(actorIndex); const metadata = actorAnimdefMetadataWithInference(actorIndex, savedMetadata[String(actorIndex)]); addActorAnimdefFormFields( form, 'stub_actor', actorIndex, label, metadata, actorPosition === 0 ? 'active' : 'passive' ); }); form._stages = { type: 'info', text: 'Stage metadata' }; analysis.stages.forEach((stage, index) => { const base = `stub_stage_${stage.stageNumber}`; const isLastStage = index === analysis.stages.length - 1; const showAdvanced = (result) => !!result?.[`${base}_advanced`]; const showAdvancedLoop = (result) => showAdvanced(result) && (result?.[`${base}_loop`] ?? stage.loop); form[`${base}_heading`] = { type: 'info', text: `Stage p${stage.stageNumber}: detected ${stage.loop ? 'looping' : 'non-looping'}, ${stage.cycleSeconds}s`, }; form[`${base}_loop`] = { label: 'Loop', type: 'checkbox', value: stage.loop, }; form[`${base}_cycle_seconds`] = { label: 'Cycle seconds', type: 'number', value: stage.cycleSeconds, min: 0.001, step: 0.001, description: 'Detected from the compiled GeckoLib animation_length.', }; form[`${base}_allow_join`] = { label: 'Allow joins', type: 'checkbox', value: !isLastStage, }; form[`${base}_escapable`] = { label: 'Escapable', type: 'checkbox', value: !isLastStage, }; form[`${base}_peaked`] = { label: 'Peaked stage', type: 'checkbox', value: isLastStage, description: "Marks this as NoN's peaked stage.", }; form[`${base}_advanced`] = { label: 'Advanced stage settings', type: 'checkbox', style: 'toggle_switch', value: false, description: 'Show optional playback and duration controls.', }; form[`${base}_speed`] = { label: 'Playback speed', type: 'text', value: '', placeholder: 'Default (1.0)', condition: showAdvanced, }; form[`${base}_stage_seconds`] = { label: 'Stage seconds', type: 'text', value: '', placeholder: 'Use NoN settings', condition: showAdvanced, }; form[`${base}_midpoint_offset`] = { label: 'Cycle midpoint offset seconds', type: 'text', value: '', placeholder: 'Default (0.0)', condition: showAdvancedLoop, }; form[`${base}_duration_multiplier`] = { label: 'Stage duration multiplier', type: 'text', value: '', placeholder: 'Default (1.0)', condition: showAdvancedLoop, }; }); const dialog = new Dialog({ id: 'create_afw_animation_definition_review', title: `Create Animation Definition: ${group.id}`, width: 760, form, buttons: ['Cancel', 'Create Stub'], cancelIndex: 0, confirmIndex: 1, onConfirm: (result) => { try { const actorMetadata = {}; const actors = []; const unresolvedLabels = []; for (const actorIndex of analysis.actorIndices) { const label = analysis.actorLabels.get(actorIndex); const metadata = readActorAnimdefFormFields(result, 'stub_actor', actorIndex); actorMetadata[String(actorIndex)] = metadata; if (!metadata.entity_types?.length) unresolvedLabels.push(label); actors.push(buildActorConstraint(label, metadata)); } const stages = analysis.stages.map((stage, index) => readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1) ); const displayName = String(result?.display_name || '').trim(); const animators = normalizeAnimatorNames(String(result?.animators || '').split(',')); const definition = {}; if (displayName) definition.display_name = displayName; if (animators.length) definition.animators = animators; const behavior = String(result?.stub_behavior || 'standard'); if (behavior === 'tease') { const multiplier = parseOptionalNumber( result?.stub_tease_multiplier, 'Nearby energy multiplier', (value) => value >= 1 && value <= 20 ); if (multiplier === null) throw new Error('Nearby energy multiplier is required.'); definition.system_tags = ['tease']; definition.tease = { near_animation_energy_multiplier: multiplier }; } else if (behavior === 'stuck') { const contentTags = parseCommaSeparatedStrings(result?.stub_stuck_content_tags); if (!contentTags.length) { throw new Error('A stuck animation needs at least one matching content tag.'); } if (!stages.some((stage) => stage.escapable)) { throw new Error('A stuck animation needs at least one escapable stage.'); } const initial = parseOptionalNumber( result?.stub_stuck_initial_difficulty, 'Initial escape difficulty', (value) => value >= 0.1 && value <= 10 ); const finalDifficulty = parseOptionalNumber( result?.stub_stuck_final_difficulty, 'Final escape difficulty', (value) => value >= 0.1 && value <= 10 ); const delay = parseOptionalNumber( result?.stub_stuck_ease_delay, 'Ease delay seconds', (value) => Number.isInteger(value) && value >= 0 && value <= 600 ); const duration = parseOptionalNumber( result?.stub_stuck_ease_duration, 'Ease duration seconds', (value) => Number.isInteger(value) && value >= 0 && value <= 600 ); const joined = parseOptionalNumber( result?.stub_stuck_joined_difficulty, 'Joined escape difficulty', (value) => value >= 0.1 && value <= 10 ); if ([initial, finalDifficulty, delay, duration, joined].some((value) => value === null)) { throw new Error('Every stuck behavior value is required.'); } if (finalDifficulty > initial) { throw new Error('Final escape difficulty cannot be harder than the initial difficulty.'); } definition.system_tags = ['stuck']; definition.content_tags = contentTags; definition.stuck = { initial_escape_difficulty_multiplier: initial, final_escape_difficulty_multiplier: finalDifficulty, ease_delay_seconds: delay, ease_duration_seconds: duration, joined_escape_difficulty_multiplier: joined, damage_behavior: String(result?.stub_stuck_damage_behavior || 'stop_on_damage'), }; } definition.actors = actors; definition.stages = stages; confirmUnresolvedActorTypes(unresolvedLabels, () => exportAnimationDefinitionStub(group, definition, actorMetadata) ); return true; } catch (e) { Blockbench.showMessageBox({ title: 'Create Animation Definition', message: e?.message || String(e), buttons: ['OK'], }); return false; } }, }); dialog.show(); } function showCreateAnimationDefinitionDialog() { let detected; try { detected = collectConjoinedAnimationGroups(); } catch (e) { Blockbench.showMessageBox({ title: 'Create Animation Definition', message: e?.message || String(e), buttons: ['OK'], }); return; } if (!detected.groups.length) { Blockbench.showMessageBox({ title: 'Create Animation Definition', message: 'No animations named .p were found in this project.', buttons: ['OK'], }); return; } const options = {}; for (const group of detected.groups) { options[group.id] = `${group.id} (${group.stages.length} stage${group.stages.length === 1 ? '' : 's'})`; } const selectedStage = parseAfwStageAnimationName(getSelectedAnimation()); const defaultId = selectedStage && options[selectedStage.id] ? selectedStage.id : detected.groups[0].id; const dialog = new Dialog({ id: 'create_afw_animation_definition', title: 'Create Animation Definition Stub', width: 620, form: { _info: { type: 'info', text: 'Select one animation ID. The plugin will detect its actors, stages, loop modes, and exact GeckoLib cycle lengths.', }, animation_id: { label: 'Animation ID', type: 'select', options, value: defaultId, }, }, buttons: ['Cancel', 'Continue'], cancelIndex: 0, confirmIndex: 1, onConfirm: (result) => { try { const selectedId = String(result?.animation_id || ''); const group = detected.groups.find((candidate) => candidate.id === selectedId); if (!group) throw new Error('No animation ID was selected.'); const analysis = analyzeAnimationDefinitionGroup(group); showAnimationDefinitionReviewDialog(group, analysis); return true; } catch (e) { Blockbench.showMessageBox({ title: 'Create Animation Definition', message: e?.message || String(e), buttons: ['OK'], }); return false; } }, }); dialog.show(); } // ---------------------------- // Import Animations // ---------------------------- function stripJsonSuffix(name) { const s = String(name || ''); return s.toLowerCase().endsWith('.json') ? s.slice(0, -5) : s; } function stripAnimationSuffix(name) { const s = String(name || ''); return s.toLowerCase().endsWith('.animation') ? s.slice(0, -10) : s; } function parseActorSuffix(base) { const marker = '_actor'; const at = base.lastIndexOf(marker); if (at === -1) return null; let i = at + marker.length; let digits = ''; while (i < base.length) { const c = base[i]; if (c < '0' || c > '9') break; digits += c; i++; } if (!digits) return null; if (i !== base.length) return null; const idx = parseInt(digits, 10); return Number.isFinite(idx) && idx > 0 ? idx : null; } function splitWeakBaseAndLabel(base) { const s = String(base || ''); const last = s.lastIndexOf('_'); if (last <= 0 || last >= s.length - 1) return { base: null, label: null }; return { base: s.slice(0, last), label: s.slice(last + 1) }; } function getFilenameParts(fileName) { const rawBase = stripAnimationSuffix(stripJsonSuffix(fileName)); // Strong rule: ..._actorN const actor = parseActorSuffix(rawBase); if (actor) { const marker = '_actor'; const at = rawBase.lastIndexOf(marker); const strictBaseName = at === -1 ? rawBase : rawBase.slice(0, at); return { rawBase, strictActorIndex: actor, strictBaseName, weakBaseName: null, weakLabel: null, }; } // Weak rule: split at last underscore: _