(function () { const PLUGIN_ID = 'AnimationDirector'; const ACTOR_NAMES_PROPERTY = 'multiactor_actor_names'; const ACTOR_TEXTURES_PROPERTY = 'multiactor_actor_textures'; const ACTOR_UV_BASIS_PROPERTY = 'animationdirector_actor_uv_basis'; 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'; const ANIMDEF_DEFINITIONS_PROPERTY = 'animationdirector_animdef_definitions'; const BLOCK_REQUIREMENTS_PROPERTY = 'animationdirector_block_requirements'; const BLOCK_GUIDE_PREFIX = '__ad_block_requirement_'; const BLOCK_GUIDE_COLORS = { required: 0x489dff, clearance: 0xff5555 }; const blockGuideMaterials = new Map(); let blockRequirementAction, blockRequirementsProjectProperty, blockGuidePreviewListener; let blockRequirementUndoSaveListener, blockRequirementUndoLoadListener; let blockRequirementPanel, blockRequirementPanelListener, blockRequirementPanelCss; let blockRequirementPendingTimer; let blockGuideProjectListener; let importAnimationDefinitionAction; let automaticImportAction; let packFoldersAction; let previousStagePoseAction; const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation']; const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews'; const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' }; let propAction, propProjectProperty, propDialog, propListener; const propMeshes = new Map(); const propSources = new Map(); let propPreviewSignature = null, propPreviewGeneration = 0; const SOUND_SOURCE_KEY = 'animationdirector_sound_source'; const SOUND_SELECTION_BLACKLIST = new Set(['needsofnature:a', 'needsofnature:funkytown']); const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview'; const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection', 'undo', 'redo']; let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition; let soundLibrary, soundLibraryGeneration = 0; const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap(); let soundAudioBytes = 0; const figuraSoundPatches = [], figuraSoundMedia = new Set(); let figuraSoundCompatEnabled = true, figuraSoundPlayListener; const SYSTEM_TAG_OPTIONS = { '': 'None (normal animation)', manualpeak: 'Manual peak', defeated: 'Defeated', stuck: 'Stuck', tease: 'Tease', birth: 'Birth', fillbottle: 'Fill bottle', other: 'Other', }; let importAction, replaceActorAction, importConjoinedAnimationAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction; let actorNamesProjectProperty, actorTexturesProjectProperty, actorUvBasisProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty; 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 formatAnimationDisplayName(animationId) { const path = String(animationId || '').split(':').pop(); return path.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim() .replace(/\b\w/g, (character) => character.toUpperCase()); } 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 getActorUvBasis() { try { return JSON.parse(Project?.[ACTOR_UV_BASIS_PROPERTY] || '{}'); } catch (_) { return {}; } } function setActorUvBasis(actorIndex, width, height) { const all = getActorUvBasis(); if (all[actorIndex]?.width === width && all[actorIndex]?.height === height) return; all[actorIndex] = { ...all[actorIndex], width, height }; Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all); Project.saved = false; } 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 getSavedAnimdef(groupId) { if (!Project) return {}; try { const saved = JSON.parse(String(Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}')); const definition = saved?.[groupId]; return definition && typeof definition === 'object' && !Array.isArray(definition) ? definition : {}; } catch (e) { console.warn('Could not read saved animation definitions:', e); return {}; } } function saveAnimdef(groupId, definition) { if (!Project) return; let saved; try { saved = JSON.parse(String(Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}')); } catch (e) { saved = {}; } if (!saved || typeof saved !== 'object' || Array.isArray(saved)) saved = {}; saved[groupId] = definition; Project[ANIMDEF_DEFINITIONS_PROPERTY] = JSON.stringify(saved); 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 injectsInto = uniqueTrimmedStrings(source.injects_into); const heldItems = uniqueTrimmedStrings(source.held_item?.items); const heldHand = String(source.held_item?.hand || 'main').trim().toLowerCase(); const heldPropSlot = String(source.held_item?.prop_slot || '').trim().toLowerCase(); 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 (injectsInto.length) out.injects_into = injectsInto; if (heldItems.length) { out.held_item = { items: heldItems, hand: heldHand }; if (heldPropSlot) out.held_item.prop_slot = heldPropSlot; } 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, actorLabels) { const base = `${prefix}_${actorIndex}`; const fieldCondition = condition || undefined; const targetOptions = {}; for (const label of actorLabels) { if (label !== actorLabel) targetOptions[label] = label; } 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}_injects_into`] = { label: 'Inject into', type: 'multi_select', options: targetOptions, value: metadata.injects_into || [], description: 'Optional. Select one or more other actors. No selection targets all eligible receivers.', condition: fieldCondition, }; form[`${base}_receiver`] = { label: 'Receiver', type: 'checkbox', value: metadata.receiver === true, description: 'An actor can both inject and receive. If any receiver is explicit, only explicit receivers are targeted.', condition: fieldCondition, }; form[`${base}_held_items`] = { label: 'Required held items', type: 'text', value: (metadata.held_item?.items || []).join(', '), placeholder: 'minecraft:carrot, #minecraft:swords', description: 'Optional item IDs or #item tags. At least one must match the selected hand.', condition: fieldCondition, }; form[`${base}_held_hand`] = { label: 'Required hand', type: 'select', options: { main: 'Main hand', off: 'Off hand', either: 'Either hand' }, value: metadata.held_item?.hand || 'main', condition: fieldCondition, }; form[`${base}_held_prop_slot`] = { label: 'Copy held stack to prop', type: 'select', options: { '': 'Do not copy', left: 'Left', right: 'Right', floor: 'Floor' }, value: metadata.held_item?.prop_slot || '', 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`], injects_into: result?.[`${base}_injects_into`] || [], held_item: { items: parseCommaSeparatedStrings(result?.[`${base}_held_items`]), hand: result?.[`${base}_held_hand`], prop_slot: result?.[`${base}_held_prop_slot`], }, }); } 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}".`); } if (metadata.injects_into?.length && !metadata.injector) { throw new Error(`Actor "${actorLabel}" needs an injector role to use injects_into.`); } for (const item of metadata.held_item?.items || []) { const id = item.startsWith('#') ? item.slice(1) : item; if (!RESOURCE_ID_PATTERN.test(id)) { throw new Error(`Actor "${actorLabel}" has invalid held item or tag "${item}". Use namespace:path or #namespace:path.`); } } } 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 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?.(); } function validUvDimension(value, fallback = 64) { return Number.isFinite(value) && value > 0 && value <= 8192 ? value : fallback; } function scaleCubeUvs(cube, x, y) { if (x === 1 && y === 1) return; if (cube.box_uv) { if (typeof cube.setUVMode !== 'function') throw new Error('Box UV conversion is unavailable in this Blockbench version.'); // Blockbench derives face UVs from uv_offset only while box UV mode is active. cube.preview_controller?.updateUV?.(cube); cube.setUVMode(false); Project.box_uv = false; } for (const face of Object.values(cube.faces || {})) { if (!Array.isArray(face.uv) || face.uv.length !== 4) continue; face.uv = [face.uv[0] * x, face.uv[1] * y, face.uv[2] * x, face.uv[3] * y]; } cube.autouv = 0; cube.preview_controller?.updateUV?.(cube); } function expandProjectUvs(width, height) { const oldWidth = validUvDimension(Project.texture_width), oldHeight = validUvDimension(Project.texture_height); const nextWidth = Math.max(oldWidth, width), nextHeight = Math.max(oldHeight, height); if (nextWidth === oldWidth && nextHeight === oldHeight) return false; for (const cube of Cube.all) scaleCubeUvs(cube, nextWidth / oldWidth, nextHeight / oldHeight); Project.texture_width = nextWidth; Project.texture_height = nextHeight; Canvas.updateAllUVs?.(); Project.saved = false; return true; } function normalizeCubeUvs(cube, actorIndex, width, height, original = null) { width = validUvDimension(width); height = validUvDimension(height); const all = getActorUvBasis(), actor = all[actorIndex]; const key = cube.uuid || cube.name; const basis = original || actor?.cubes?.[key] || actor || { width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) }; const oldWidth = validUvDimension(basis.width), oldHeight = validUvDimension(basis.height); expandProjectUvs(width, height); const x = oldWidth / width, y = oldHeight / height; scaleCubeUvs(cube, x, y); if (actor?.cubes?.[key]?.width !== width || actor?.cubes?.[key]?.height !== height) { all[actorIndex] = { ...(actor || basis), cubes: { ...actor?.cubes, [key]: { width, height } } }; Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all); Project.saved = false; } return x !== 1 || y !== 1; } function normalizeActorUvs(actorIndex, width, height, original = null) { let changed = false; for (const cube of Cube.all) { if (cubeBelongsToActor(cube, actorIndex)) changed = normalizeCubeUvs(cube, actorIndex, width, height, original) || changed; } expandProjectUvs(validUvDimension(width), validUvDimension(height)); setActorUvBasis(actorIndex, width, height); return changed; } // ---------------------------- // Block requirement authoring // ---------------------------- function getBlockRequirementEntries() { try { const entries = JSON.parse(Project?.[BLOCK_REQUIREMENTS_PROPERTY] || '{}'); return entries && typeof entries === 'object' && !Array.isArray(entries) ? entries : {}; } catch (e) { return {}; } } function getAuthoredBlockRequirement(animationId) { return getBlockRequirementEntries()[animationId] || null; } function blockRequirementInteger(value, label, minimum = 1) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < minimum || parsed > 32) { throw new Error(`${label} must be a whole number between ${minimum} and 32.`); } return parsed; } function readBlockRequirementForm(result) { const blocks = uniqueTrimmedStrings(parseCommaSeparatedStrings(result.blocks)); for (const block of blocks) { if (!/^#?[a-z0-9_.-]+:[a-z0-9/._-]+$/.test(block)) { throw new Error(`Invalid block ID/tag "${block}". Use minecraft:stone or #minecraft:fences.`); } } const requirement = { type: result.type }; if (blocks.length) requirement.blocks = blocks.length === 1 ? blocks[0] : blocks; if (result.type === 'wall') { const minHeight = blockRequirementInteger(result.height_min, 'Minimum height'); const minWidth = blockRequirementInteger(result.width_min, 'Minimum width'); const maxHeight = String(result.height_max ?? '').trim() ? blockRequirementInteger(result.height_max, 'Maximum height') : null; const maxWidth = String(result.width_max ?? '').trim() ? blockRequirementInteger(result.width_max, 'Maximum width') : null; if (maxHeight !== null && maxHeight < minHeight) throw new Error('Maximum height must be at least the minimum height.'); if (maxWidth !== null && maxWidth < minWidth) throw new Error('Maximum width must be at least the minimum width.'); requirement.height = { min: minHeight }; if (maxHeight !== null) requirement.height.max = maxHeight; requirement.width = { min: minWidth }; if (maxWidth !== null) { requirement.width.max = maxWidth; if (result.both_axes) requirement.width.both_axes = true; if (result.include_obstructions) requirement.width.include_obstructions = true; } if (result.facing) { if (!blocks.length) throw new Error('A facing requirement needs a block ID or tag.'); requirement.facing = result.facing; } } else if (result.type === 'center_support') { requirement.support = result.support; if (result.support === 'surface' && !blocks.length) throw new Error('Custom surface support requires a block ID or tag.'); requirement.placement = result.placement; if (result.placement === 'surface_footprint') { requirement.surface_footprint = { width: blockRequirementInteger(result.footprint_width, 'Footprint width'), depth: blockRequirementInteger(result.footprint_depth, 'Footprint depth'), height: blockRequirementInteger(result.footprint_height, 'Footprint clearance height'), margin: blockRequirementInteger(result.footprint_margin, 'Footprint margin', 0), }; } else { requirement.surface_radius = blockRequirementInteger(result.surface_radius, 'Surface radius', 0); } } else { throw new Error('Choose a wall or center support requirement.'); } if (requirement.placement !== 'surface_footprint') { requirement.clearance = { width: blockRequirementInteger(result.clearance_width, 'Clearance width'), height: blockRequirementInteger(result.clearance_height, 'Clearance height'), depth: blockRequirementInteger(result.clearance_depth, 'Clearance depth'), }; } const wallThickness = Number(result.wall_thickness ?? 1); const surfaceHeight = Number(result.surface_height ?? 1); if (!Number.isFinite(wallThickness) || wallThickness <= 0 || wallThickness > 1) throw new Error('Preview wall thickness must be above 0 and at most 1 block.'); if (!Number.isFinite(surfaceHeight) || surfaceHeight <= 0 || surfaceHeight > 2) throw new Error('Preview surface height must be above 0 and at most 2 blocks.'); return { requirement, preview: { wallThickness, surfaceHeight } }; } function buildBlockRequirementGuideBoxes(entry) { const req = entry.requirement; const boxes = []; const add = (kind, from, to, label) => { if (boxes.length >= 4096) throw new Error('This preview would exceed 4096 guide cubes. Reduce its dimensions.'); boxes.push({ kind, from: from.map((value) => value * 16), to: to.map((value) => value * 16), label }); }; const cell = (kind, x, y, z, height = 1, label = kind) => add(kind, [x, y, z], [x + 1, y + height, z + 1], label); const clearance = req.clearance; if (req.type === 'wall') { const width = req.width.min; const thickness = entry.preview?.wallThickness ?? 1; // NoN places the anchor half a block away from the wall's collision surface. const gridZ = -1 - thickness / 2; const left = -width / 2; for (let x = 0; x < width; x++) { for (let y = 0; y < req.height.min; y++) { add('required', [left + x, y, -0.5 - thickness], [left + x + 1, y + 1, -0.5], 'wall'); } cell('required', left + x, -1, gridZ, 1, 'floor'); if (req.height.max === req.height.min) cell('clearance', left + x, req.height.max, gridZ, 1, 'height_cap'); } const padding = clearance.width - 1; for (let x = -padding; x < width + padding; x++) { for (let z = 1; z <= clearance.depth; z++) { for (let y = 0; y < clearance.height; y++) cell('clearance', left + x, y, gridZ + z); cell('required', left + x, -1, gridZ + z, 1, 'floor'); } } } else { const top = req.support === 'slab' || req.support === 'half_height' ? 0.5 : req.support === 'full_block' ? 1 : entry.preview?.surfaceHeight ?? 1; if (req.placement === 'surface_footprint') { const footprint = req.surface_footprint; for (let x = -footprint.margin; x < footprint.width + footprint.margin; x++) { for (let z = -footprint.margin; z < footprint.depth + footprint.margin; z++) { if (x >= 0 && x < footprint.width && z >= 0 && z < footprint.depth) { cell('required', x - 0.5, 0, z - 0.5, top, 'support'); } for (let y = 0; y < footprint.height; y++) cell('clearance', x - 0.5, top + y, z - 0.5); } } } else { cell('required', -0.5, 0, -0.5, top, 'support'); const radius = req.surface_radius || 0; for (let x = -radius; x <= radius; x++) { for (let z = -radius; z <= radius; z++) { for (let y = 0; y < clearance.height; y++) cell('clearance', x - 0.5, top + y, z - 0.5, 1, 'above_support'); } } const padding = clearance.width - 1; for (let x = -padding; x <= padding; x++) { for (let z = 1; z <= clearance.depth; z++) { for (let y = 0; y < clearance.height; y++) cell('clearance', x - 0.5, y, z - 0.5); cell('required', x - 0.5, -1, z - 0.5, 1, 'floor'); } } } } // Merge overlapping clearance checks (surface-radius and directional checks can overlap). const columns = new Map(); for (const box of boxes) { const key = [box.kind, box.from[0], box.from[2], box.to[0], box.to[2]].join(':'); if (!columns.has(key)) columns.set(key, []); columns.get(key).push(box); } const merged = []; for (const column of columns.values()) { column.sort((a, b) => a.from[1] - b.from[1]); let current; for (const box of column) { if (current && box.from[1] < current.to[1]) current.to[1] = Math.max(current.to[1], box.to[1]); else { current = { ...box, from: box.from.slice(), to: box.to.slice() }; merged.push(current); } } } return merged; } function isBlockRequirementGuide(node) { if (String(node?.name || '').startsWith(BLOCK_GUIDE_PREFIX)) return true; return node?.getAllAncestors?.().some((parent) => String(parent.name || '').startsWith(BLOCK_GUIDE_PREFIX)) || false; } function applyBlockRequirementGuideMaterial(cube) { if (!isBlockRequirementGuide(cube) || !cube.mesh || typeof THREE === 'undefined') return; const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required'; let material = blockGuideMaterials.get(kind); if (!material) { material = new THREE.MeshBasicMaterial({ color: BLOCK_GUIDE_COLORS[kind], transparent: true, opacity: 0.22, depthWrite: false, side: THREE.DoubleSide }); blockGuideMaterials.set(kind, material); } cube.mesh.material = material; } function hideInternalBlockGuideFaces(cubes) { const faces = [['west', 0, 0], ['east', 0, 1], ['down', 1, 0], ['up', 1, 1], ['north', 2, 0], ['south', 2, 1]]; const planes = new Map(); const descriptors = []; const epsilon = 0.000001; for (const cube of cubes) { const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required'; for (const [name, axis, positive] of faces) { if (!cube.faces?.[name]) continue; const axes = [0, 1, 2].filter((value) => value !== axis); const rect = [cube.from[axes[0]], cube.from[axes[1]], cube.to[axes[0]], cube.to[axes[1]]]; const key = `${kind}:${axis}:${(positive ? cube.to[axis] : cube.from[axis]).toFixed(6)}`; const descriptor = { cube, name, positive, rect, key }; if (!planes.has(key)) planes.set(key, [[], []]); planes.get(key)[positive].push(descriptor); descriptors.push(descriptor); } } for (const face of descriptors) { let uncovered = [face.rect]; // Subtract neighboring rectangles; several smaller faces can cover one large face. for (const neighbor of planes.get(face.key)[1 - face.positive]) { if (neighbor.cube === face.cube) continue; const next = []; for (const rect of uncovered) { const left = Math.max(rect[0], neighbor.rect[0]), bottom = Math.max(rect[1], neighbor.rect[1]); const right = Math.min(rect[2], neighbor.rect[2]), top = Math.min(rect[3], neighbor.rect[3]); if (right - left <= epsilon || top - bottom <= epsilon) { next.push(rect); continue; } if (left - rect[0] > epsilon) next.push([rect[0], rect[1], left, rect[3]]); if (rect[2] - right > epsilon) next.push([right, rect[1], rect[2], rect[3]]); if (bottom - rect[1] > epsilon) next.push([left, rect[1], right, bottom]); if (rect[3] - top > epsilon) next.push([left, top, right, rect[3]]); } uncovered = next; if (!uncovered.length) break; } // Null faces are omitted from Blockbench's mesh index, not just made transparent. face.cube.faces[face.name].texture = uncovered.length ? false : null; } } function refreshBlockGuideFaces() { const roots = Group.all.filter((group) => group.name.startsWith(BLOCK_GUIDE_PREFIX) && !isBlockRequirementGuide(group.parent)); const elements = []; for (const root of roots) { const cubes = Cube.all.filter((cube) => cube.getAllAncestors().includes(root)); hideInternalBlockGuideFaces(cubes); elements.push(...cubes); } if (elements.length) Canvas.updateView({ elements, element_aspects: { faces: true, uv: true } }); } function rebuildBlockRequirementGuides(animationId, entry, importedDefinition, manageUndo = true, importedProps) { const boxes = entry ? buildBlockRequirementGuideBoxes(entry) : []; const groupName = `${BLOCK_GUIDE_PREFIX}${animationId}`; const oldGroups = Group.all.filter((group) => group.name === groupName); const oldElements = Cube.all.filter((cube) => cube.getAllAncestors().some((group) => oldGroups.includes(group))); const entries = getBlockRequirementEntries(); const updated = { ...entries }; if (entry) updated[animationId] = entry; else updated[animationId] = { requirement: null }; const createdElements = []; const createdGroups = []; if (manageUndo) Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups, block_requirement_edit: true, animdef_edit: importedDefinition !== undefined, prop_preview_edit: importedProps !== undefined }); if (entry) { const root = oldGroups[0] || new Group({ name: groupName, export: false }); if (!oldGroups.length) { root.init(); root.addTo('root'); } createdGroups.push(root); boxes.forEach((box, index) => { const data = { name: `${BLOCK_GUIDE_PREFIX}${box.kind}_${box.label}_${index + 1}`, from: box.from, to: box.to, export: false, box_uv: false }; const cube = oldElements[index] || new Cube(data); if (oldElements[index]) { cube.name = data.name; cube.from = box.from.slice(); cube.to = box.to.slice(); cube.export = false; } else { cube.init(); cube.addTo(root); } createdElements.push(cube); }); for (const cube of oldElements.slice(boxes.length)) cube.remove(); } else { for (const group of oldGroups) group.remove(); } Project[BLOCK_REQUIREMENTS_PROPERTY] = JSON.stringify(updated); if (importedDefinition !== undefined) saveAnimdef(animationId, importedDefinition); if (importedProps !== undefined) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(importedProps); hideInternalBlockGuideFaces(createdElements); // Rebuilding every cube would reset forced actor materials in single-texture formats. Canvas.updateView({ elements: createdElements, groups: createdGroups, element_aspects: { transform: true, geometry: true, faces: true, uv: true, visibility: true } }); for (const cube of createdElements) applyBlockRequirementGuideMaterial(cube); if (manageUndo) Undo.finishEdit(importedDefinition !== undefined ? 'Import animation definition' : entry ? 'Set block requirement' : 'Remove block requirement', { outliner: true, elements: createdElements, groups: createdGroups, block_requirement_edit: true, animdef_edit: importedDefinition !== undefined, prop_preview_edit: importedProps !== undefined }); Project.saved = false; } function buildBlockRequirementForm(animationId, importedRequirement) { const entry = getAuthoredBlockRequirement(animationId); const req = importedRequirement ?? (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) ?? {}; const wall = (result) => result?.type === 'wall'; const center = (result) => result?.type === 'center_support'; const directional = (result) => wall(result) || result?.placement !== 'surface_footprint'; const footprint = (result) => center(result) && result?.placement === 'surface_footprint'; const rangeMin = (range, fallback) => typeof range === 'number' ? range : range?.min ?? fallback; const rangeMax = (range) => typeof range === 'number' ? range : range?.max ?? ''; const dimension = (label, value, condition, min = 1) => ({ label, type: 'number', value, min, max: 32, step: 1, condition }); const form = { _about: { type: 'info', text: `### Block requirement: ${animationId}\nBlue = required structure/floor. Red = clearance. ` + 'Guides show the minimum valid layout at the animation origin (16 pixels = 1 block). Clearance extends toward +Z; actors face the wall toward -Z. ' + 'Guides are excluded from model and animation exports; the requirement is included in Create Animation Definition.' }, type: { label: 'Requirement type', type: 'select', value: req.type || 'wall', options: { wall: 'Wall', center_support: 'Center support' } }, blocks: { label: 'Block IDs / tags', type: 'text', value: (Array.isArray(req.blocks) ? req.blocks : req.blocks ? [req.blocks] : []).join(', '), placeholder: '#minecraft:fences, minecraft:stone', description: 'Comma-separated alternatives. Leave empty for any supported wall/support. Required for custom surfaces and facing.' }, _wall: { type: 'info', text: '### Wall structure', condition: wall }, height_min: dimension('Minimum height', rangeMin(req.height, req.height?.max != null ? 1 : 2), wall), height_max: { label: 'Maximum height', type: 'text', value: rangeMax(req.height), placeholder: 'No limit', condition: wall }, width_min: dimension('Minimum width', rangeMin(req.width, 1), wall), width_max: { label: 'Maximum width', type: 'text', value: req.width?.max ?? '', placeholder: 'No limit', condition: wall }, both_axes: { label: 'Maximum width: both axes', type: 'checkbox', value: req.width?.both_axes || false, condition: (result) => wall(result) && !!String(result?.width_max || '').trim(), description: 'Also check the front/back axis at the required height levels.' }, include_obstructions: { label: 'Maximum width: count obstructions', type: 'checkbox', value: req.width?.include_obstructions || false, condition: (result) => wall(result) && !!String(result?.width_max || '').trim(), description: 'Count any collidable neighbor, including blocks outside the IDs/tags above.' }, facing: { label: 'Required block face', type: 'select', value: req.facing || '', condition: wall, options: { '': 'Any facing', front: 'Front', back: 'Back', left: 'Left', right: 'Right' }, description: 'The block face pointing toward the animation area. Stairs: the step face is Back.' }, _center: { type: 'info', text: '### Center support', condition: center }, support: { label: 'Support', type: 'select', value: req.support || 'full_block', condition: center, options: { full_block: 'Full block', slab: 'Bottom slab', half_height: 'Bottom slab or bed', surface: 'Custom surface (IDs/tags)' } }, placement: { label: 'Placement', type: 'select', value: req.placement || 'directional_clearance', condition: center, options: { directional_clearance: 'Directional clearance', surface_footprint: 'Surface footprint' } }, surface_radius: dimension('Surrounding surface radius', req.surface_radius ?? 0, (result) => center(result) && directional(result), 0), footprint_width: dimension('Footprint width', req.surface_footprint?.width ?? 1, footprint), footprint_depth: dimension('Footprint depth', req.surface_footprint?.depth ?? 1, footprint), footprint_height: dimension('Clearance above footprint', req.surface_footprint?.height ?? 2, footprint), footprint_margin: dimension('Free-space border', req.surface_footprint?.margin ?? 0, footprint, 0), _clearance: { type: 'info', text: '### Directional clearance', condition: directional }, clearance_width: dimension('Clearance width', req.clearance?.width ?? 1, directional), _width_hint: { type: 'info', text: 'Width 1 checks directly beside the support / across the matched wall. Width 2 adds one block on each side.', condition: directional }, clearance_height: dimension('Clearance height', req.clearance?.height ?? 2, directional), clearance_depth: dimension('Clearance depth', req.clearance?.depth ?? 1, directional), _preview: { type: 'info', text: '### Preview shape (editor only)\nActual collision shapes depend on the block state. These values adjust only the guide, not the exported requirement. Maximum limits/facing remain matching rules; the guide shows the minimum structure.' }, wall_thickness: { label: 'Wall thickness (blocks)', type: 'number', value: entry?.preview?.wallThickness ?? 1, min: 0.0625, max: 1, step: 0.0625, condition: wall, description: 'Full block: 1. Fence post: 0.25. Adjust to the intended collision surface.' }, surface_height: { label: 'Custom surface height', type: 'number', value: entry?.preview?.surfaceHeight ?? 1, min: 0.0625, max: 2, step: 0.0625, condition: (result) => center(result) && result?.support === 'surface', description: 'For example a bed is 0.5625 blocks high; slab/half-height mode uses 0.5.' }, }; return form; } function cancelBlockRequirementUpdate() { if (blockRequirementPendingTimer !== undefined) clearTimeout(blockRequirementPendingTimer); blockRequirementPendingTimer = undefined; } function blockRequirementAnimationIds() { let groups = []; try { groups = collectConjoinedAnimationGroups().groups; } catch (_) { /* Allow authoring before clips exist. */ } return Array.from(new Set([...groups.map((group) => group.id), ...Object.keys(getBlockRequirementEntries())])).sort(); } function createBlockRequirementPanel() { blockRequirementPanelCss = Blockbench.addCSS(` .ad-block-panel { padding: 10px; overflow-y: auto; height: 100%; box-sizing: border-box; } .ad-block-panel p { margin: 6px 0; font-size: 12px; color: var(--color-text); opacity: .8; } .ad-block-panel .ad-block-target { display: flex; gap: 6px; margin-bottom: 8px; } .ad-block-panel .ad-block-target input { width: 0; flex: 1; } .ad-block-panel select { width: 100%; min-width: 0; } .ad-block-panel details { margin: 10px 0; border: 1px solid var(--color-border); border-radius: 4px; padding: 8px; } .ad-block-panel summary { font-weight: bold; cursor: pointer; color: var(--color-accent); } .ad-block-panel .ad-block-field { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; } .ad-block-panel .ad-block-field label { width: 100%; font-size: 12px; } .ad-block-panel .ad-block-field input:not([type=checkbox]) { width: 100%; box-sizing: border-box; } .ad-block-panel .ad-block-error { color: var(--color-error); opacity: 1; } .ad-block-panel .ad-block-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } `); blockRequirementPanel = new Panel('animationdirector_block_requirements', { name: 'Block Requirements', icon: 'view_in_ar', condition: () => !!Project, growable: true, resizable: true, min_height: 240, expand_button: true, default_position: { slot: 'left_bar', height: 520, float_size: [360, 620] }, component: { data() { return { projectId: null, animationId: '', draftId: '', ids: [], form: {}, values: {}, error: '', status: '', dirty: false }; }, computed: { sections() { const sections = [{ title: 'Requirement', fields: [] }]; for (const [key, field] of Object.entries(this.form)) { if (key === '_about') continue; if (field.condition && !field.condition(this.values)) continue; if (field.type === 'info' && field.text.startsWith('### ')) { sections.push({ title: field.text.slice(4).split('\n')[0], fields: [], hint: field.text.split('\n').slice(1).join('\n') }); } else sections[sections.length - 1].fields.push({ key, ...field }); } return sections.filter((section) => section.fields.length); }, }, methods: { load(id) { cancelBlockRequirementUpdate(); this.projectId = Project?.uuid || null; this.ids = Project ? blockRequirementAnimationIds() : []; this.animationId = id || ''; this.draftId = this.animationId; this.form = Project && id ? buildBlockRequirementForm(id) : {}; this.values = Object.fromEntries(Object.entries(this.form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value])); this.error = ''; this.dirty = false; this.status = id ? 'Changes update the guides and are saved in this project automatically.' : 'Select an animation or enter a new animation ID.'; }, openId() { const id = String(this.draftId).trim(); if (!/^[a-z0-9._-]+$/.test(id)) { this.error = 'Enter a valid lowercase animation ID (without the stage suffix).'; return; } this.flush(); this.load(id); }, selectId(event) { this.flush(); this.load(event.target.value); }, input(key, field, event) { this.$set(this.values, key, field.type === 'checkbox' ? event.target.checked : event.target.value); this.dirty = true; cancelBlockRequirementUpdate(); try { buildBlockRequirementGuideBoxes(readBlockRequirementForm(this.values)); this.error = ''; } catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; return; } // Coalesce a typing/arrow-key burst without keeping a native Undo edit open. blockRequirementPendingTimer = setTimeout(() => this.flush(), 180); }, flush(force = false) { cancelBlockRequirementUpdate(); if (!this.animationId || !Project || this.projectId !== Project.uuid || (!this.dirty && !force)) return; try { const entry = readBlockRequirementForm(this.values); buildBlockRequirementGuideBoxes(entry); const hasGuides = Group.all.some((group) => group.name === `${BLOCK_GUIDE_PREFIX}${this.animationId}`); if (!hasGuides || JSON.stringify(entry) !== JSON.stringify(getAuthoredBlockRequirement(this.animationId))) { rebuildBlockRequirementGuides(this.animationId, entry); } this.error = ''; this.dirty = false; this.ids = blockRequirementAnimationIds(); this.status = 'Preview updated. Blue: structure / floor. Red: clearance. Undo is available.'; } catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; } }, remove() { cancelBlockRequirementUpdate(); if (!this.animationId || !Project || this.projectId !== Project.uuid) return; rebuildBlockRequirementGuides(this.animationId, null); this.load(this.animationId); this.status = 'Requirement removed. Edit a value or click Build guides to add it again.'; }, }, template: `

Live guides: 16 pixels = 1 block. Actors face -Z; wall clearance extends toward +Z. Guides are not exported.

{{ error }}

{{ status }}

{{ section.title }}

{{ section.hint }}

{{ field.text }}

`, }, }); blockRequirementPanelListener = () => { cancelBlockRequirementUpdate(); const view = blockRequirementPanel?.vue; if (view) view.load(Project && view.projectId === Project.uuid ? view.animationId : ''); }; Blockbench.on('select_project unselect_project undo redo', blockRequirementPanelListener); } function addBlockRequirement() { if (!Project) return Blockbench.showQuickMessage('Open a project first.', 2500); if (!blockRequirementPanel) createBlockRequirementPanel(); const view = blockRequirementPanel.vue; view.flush(); const selected = parseAfwStageAnimationName(getSelectedAnimation()); const ids = blockRequirementAnimationIds(); view.load(selected?.id || (view.projectId === Project.uuid && view.animationId) || ids[0] || ''); if (blockRequirementPanel.slot === 'hidden') blockRequirementPanel.moveTo('left_bar'); blockRequirementPanel.folded = false; blockRequirementPanel.getContainerPanel().selectTab(blockRequirementPanel); blockRequirementPanel.update(); if (blockRequirementPanel.slot === 'float') blockRequirementPanel.moveToFront(); } function stripBlockRequirementGuideBones(clip) { if (clip?.bones) { for (const name of Object.keys(clip.bones)) { if (name.startsWith(BLOCK_GUIDE_PREFIX)) delete clip.bones[name]; } } return clip; } // ---------------------------- // Import Actor Geometry // ---------------------------- function removeActorGeometry(actorIndex) { const prefix = `actor${actorIndex}_`; const roots = Group.all.filter((group) => { if (!group?.name?.startsWith(prefix)) return false; return !(group.parent instanceof Group && group.parent.name?.startsWith(prefix)); }); for (const root of roots) root.remove(); } function captureActorAnimationBindings(actorIndex) { const prefix = `actor${actorIndex}_`; const bindings = new Map(); for (const group of Group.all || []) { if (group?.name?.startsWith(prefix)) bindings.set(group.name, group.uuid); } // Removing a group deletes its animators, so retain the tracks before removal. const animations = typeof Animation !== 'undefined' && Array.isArray(Animation.all) ? Animation.all : []; return animations.map((animation) => ({ animation, tracks: Array.from(bindings, ([boneName, uuid]) => ({ boneName, uuid, animator: animation.animators?.[uuid] })) .filter((track) => track.animator), })).filter((entry) => entry.tracks.length); } function rebindActorAnimations(actorIndex, previousBindings) { if (!previousBindings?.length) { return { rebound: 0, missing: 0 }; } const prefix = `actor${actorIndex}_`; const replacementGroups = new Map(); for (const group of Group.all || []) { if (group?.name?.startsWith(prefix)) replacementGroups.set(group.name, group); } let rebound = 0; let missing = 0; for (const { animation, tracks } of previousBindings) { const animators = animation.animators; if (!animators || typeof animators !== 'object') continue; for (const { boneName, uuid: oldUuid, animator } of tracks) { const replacement = replacementGroups.get(boneName); if (!replacement) { animator._name = boneName; animators[oldUuid] = animator; animation.saved = false; missing++; continue; } if (typeof animation.removeAnimator === 'function') animation.removeAnimator(oldUuid); else delete animators[oldUuid]; animator.uuid = replacement.uuid; animator._name = replacement.name; animators[replacement.uuid] = animator; animation.saved = false; rebound++; } } return { rebound, missing }; } function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false, manageUndo = true) { 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}_`; const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null; if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: Cube.all.slice(), textures: [], uv_mode: true, actor_uv_edit: true, animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] }); if (replaceExisting) removeActorGeometry(actorIndex); const uvWidth = validUvDimension(geo?.description?.texture_width); const uvHeight = validUvDimension(geo?.description?.texture_height); expandProjectUvs(uvWidth, uvHeight); 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)); const locators = b?.locators && typeof b.locators === 'object' && !Array.isArray(b.locators) ? b.locators : {}; for (const [locatorName, locatorData] of Object.entries(locators)) { const position = Array.isArray(locatorData) ? locatorData : locatorData?.offset || locatorData?.position || locatorData?.from; if (!Array.isArray(position)) continue; const locator = new Locator({ name: `${prefix}${locatorName}`, from: convertPos(v3(position, 0)), }); locator.init(); locator.addTo(g); } } // 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)]; cube.preview_controller?.updateUV?.(cube); } else if (c?.uv && typeof c.uv === 'object') { cube.box_uv = false; applyPerFaceUVWithXFlip(cube, c.uv); } } } normalizeActorUvs(actorIndex, uvWidth, uvHeight, { width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) }); const animationResult = replaceExisting ? rebindActorAnimations(actorIndex, previousBindings) : { rebound: 0, missing: 0 }; if (manageUndo) { Canvas.updateAll(); applyAllActorTexturePreviews(false); } if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select(); if (manageUndo) Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`); const animationNotice = replaceExisting && animationResult.rebound ? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}` : ''; if (manageUndo) Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000); if (replaceExisting && animationResult.missing) { Blockbench.showMessageBox({ title: 'Replacement Model Missing Animated Bones', message: `${animationResult.missing} existing animation channel${animationResult.missing === 1 ? '' : 's'} could not be rebound because the replacement model does not contain the matching actor bone. Those channels were left unchanged.`, buttons: ['OK'], }); } } function pngDimensions(bytes) { if (!bytes || bytes.length < 24 || bytes.toString('hex', 0, 8) !== '89504e470d0a1a0a') return null; const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20); return width > 0 && height > 0 && width <= 8192 && height <= 8192 ? { width, height } : null; } function actorTextureCandidates(json, geo, modelPath, entityId = '', slimPlayer = false) { const candidateIds = []; const addId = (id) => { if (importResourceId(id) && !candidateIds.includes(id)) candidateIds.push(id); }; if (typeof json.afw_texture === 'string') addId(json.afw_texture); const path = String(modelPath || '').replace(/\\/g, '/'); const namespace = /\/assets\/([^/]+)\//.exec(path)?.[1] || 'minecraft'; const identifier = String(geo?.description?.identifier || '').split('.').pop(); const filename = path.split('/').pop()?.replace(/(?:\.(?:m|f|mf|m\+f))?\.geo\.json$/i, '') || ''; for (const entity of [...new Set([identifier, filename])]) { if (!/^[a-z0-9_]+$/.test(entity)) continue; addId(`${namespace}:textures/entity/${entity}/${entity}.png`); addId(`${namespace}:textures/entity/${entity}.png`); if (namespace !== 'minecraft') { addId(`minecraft:textures/entity/${entity}/${entity}.png`); addId(`minecraft:textures/entity/${entity}.png`); } } if (entityId === 'minecraft:player' || filename === 'player' || filename === 'player_slim') { addId(`minecraft:textures/entity/player/${slimPlayer || filename === 'player_slim' ? 'slim' : 'wide'}/alex.png`); } return candidateIds; } function importTextureDimensions(bytes, metadata = null) { let dimensions = pngDimensions(bytes); if (!dimensions) return null; const animation = metadata?.animation; if (animation) { const width = validUvDimension(animation.width, dimensions.width); const height = validUvDimension(animation.height, width); if (width <= dimensions.width && height <= dimensions.height) dimensions = { width, height }; } return dimensions; } function tryLoadImportedActorTexture(json, geo, modelPath, actorIndex) { let io; try { io = desktopFiles(); } catch (_) { return; } const candidateIds = actorTextureCandidates(json, geo, modelPath); if (!candidateIds.length) return; const sourcePaths = []; try { sourcePaths.push(findImportPack(modelPath, io).root); } catch (_) { /* Standalone model. */ } try { sourcePaths.push(...savedPackFolders()); } catch (_) { /* No registered packs. */ } try { sourcePaths.push(...discoverPropSources(io).map((source) => source.file)); } catch (_) { /* No cached assets. */ } const sources = []; for (const sourcePath of [...new Set(sourcePaths)]) { try { sources.push(openPropSource(sourcePath, io)); } catch (_) { /* Try other asset sources. */ } } for (const id of candidateIds) { for (const source of sources) { const parsed = importResourceId(id); const relative = `assets/${parsed.namespace}/${parsed.resource}`; let bytes; try { bytes = source.read(relative); } catch (_) { continue; } if (!bytes || bytes.length > 4 * 1024 * 1024) continue; let dimensions = pngDimensions(bytes); if (!dimensions) continue; try { dimensions = importTextureDimensions(bytes, propJson(source, `${relative}.mcmeta`)); } catch (_) { /* Keep the PNG dimensions when metadata is invalid. */ } const edit = { elements: Cube.all.filter((cube) => cubeBelongsToActor(cube, actorIndex)), textures: Texture.all.slice(), uv_mode: true, actor_uv_edit: true, actor_texture_edit: true }; Undo.initEdit(edit); try { const name = `actor${actorIndex}_${parsed.namespace}_${parsed.resource.split('/').pop()}`; const texture = new Texture({ name, uv_width: dimensions.width, uv_height: dimensions.height }); texture.keep_size = true; const projectId = Project.uuid; texture.load_callback = () => { if (Project?.uuid === projectId) scheduleActorTexturePreviewApply(); }; texture.fromDataURL(`data:image/png;base64,${bytes.toString('base64')}`); texture.name = name; texture.relative_path = relative; texture.add(false); const selected = getProjectActorTextures(); selected[actorIndex] = name; setProjectActorTextures(selected); normalizeActorUvs(actorIndex, dimensions.width, dimensions.height); applyActorTexturePreview(actorIndex, name); Undo.finishEdit(`Load Actor ${actorIndex} Texture`, { ...edit, elements: Cube.all.slice(), textures: Texture.all.slice() }); return; } catch (error) { Undo.cancelEdit(); throw error; } } } } 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; } const actorIndex = getNextActorIndex(); importGeometryIntoCurrentProject(geo, actorIndex); tryLoadImportedActorTexture(json, geo, files[0]?.path, actorIndex); } catch (e) { console.error(e); Blockbench.showQuickMessage(`Import failed: ${e?.message || e}`, 4000); } } ); } function replaceActorGeckoJson() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); return; } const actorOptions = buildActorSelectOptions(); if (!Object.keys(actorOptions).length) { Blockbench.showQuickMessage('No actorN_ model found in this project.', 2500); return; } new Dialog({ id: 'replace_actor_gecko_json', title: 'Replace Actor Model', width: 520, form: { actor: { label: 'Actor to replace', type: 'select', options: actorOptions, value: Object.keys(actorOptions)[0], }, _info: { type: 'info', text: 'The selected actor model will be replaced. Its actor prefix, animations, and actor preferences are preserved.', }, }, buttons: ['Cancel', 'Choose Model'], cancelIndex: 0, confirmIndex: 1, onConfirm: (result) => { const actorIndex = Number(result?.actor); if (!Number.isFinite(actorIndex) || actorIndex <= 0) { Blockbench.showQuickMessage('Choose a valid actor.', 2500); return false; } Filesystem.importFile( { type: 'GeckoLib / Bedrock Geometry JSON', extensions: ['json', 'geo.json'], multiple: false, readtype: 'text', resource_id: 'model', title: 'Select replacement actor model', }, (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, actorIndex, true); tryLoadImportedActorTexture(json, geo, files[0]?.path, actorIndex); } catch (e) { console.error(e); Blockbench.showQuickMessage(`Replacement failed: ${e?.message || e}`, 4000); } } ); return true; }, }).show(); } // ---------------------------- // 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 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, actorIndices.map((actor) => names[String(actor)] || `actor${actor}`) ); }); 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) => { let editing = false; 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, }; } } const aspects = { elements: Cube.all.slice(), uv_mode: true, actor_preferences_edit: true }; Undo.initEdit(aspects); editing = true; setProjectActorNames(outNames); setProjectActorTextures(outTextures); setProjectActorAnimdefMetadata(outAnimdefMetadata); setProjectBoneTextureOverrides(outBoneTextureOverrides); applyAllActorTexturePreviews(true); Undo.finishEdit('Edit Actor Preferences', { ...aspects, elements: Cube.all.slice() }); return true; } catch (e) { if (editing) Undo.cancelEdit(); 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 && !isBlockRequirementGuide(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 actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name); const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || texture.uv_width || 0; const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || texture.uv_height || 0; if (actorIndex && imageWidth > 0 && imageHeight > 0) normalizeCubeUvs(cube, actorIndex, imageWidth, imageHeight); 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 overrides = getProjectBoneTextureOverrides(); const cubes = Array.isArray(Cube.all) ? Cube.all : []; for (const cube of cubes) { if (!cubeBelongsToActor(cube, actorIndex)) continue; const override = getNearestBoneTextureOverride(cube, overrides); if (override?.texture && findTextureByActorTextureName(override.texture)) 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); if (isBlockRequirementGuide(group) || String(override?.name || '').startsWith(BLOCK_GUIDE_PREFIX)) continue; 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) { if (isBlockRequirementGuide(cube)) continue; 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'])) { for (const geometry of json['minecraft:geometry']) { const excluded = new Set(); const bones = geometry.bones || []; for (const bone of bones) if (bone.name?.startsWith(BLOCK_GUIDE_PREFIX)) excluded.add(bone.name); // Also exclude descendants if a creator has renamed a guide bone. let previousSize; do { previousSize = excluded.size; for (const bone of bones) if (excluded.has(bone.parent)) excluded.add(bone.name); } while (excluded.size !== previousSize); geometry.bones = bones.filter((bone) => !excluded.has(bone.name)); } 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 exportAnimationOutputs(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 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 previousStagePoseSource() { if (typeof Format === 'undefined' || Format.id !== 'geckolib_model') return { reason: 'Open a GeckoLib animation project first.' }; const current = parseAfwStageAnimationName(Animation.selected); if (!current) return { reason: 'Select an animation stage named animationID.pN.' }; const stages = getSavedAnimdef(current.id).stages || []; const candidates = [...Animation.all.map(parseAfwStageAnimationName).filter((stage) => stage?.id === current.id), ...stages.map((stage) => ({ stageNumber: stage.stage }))].filter((stage) => stage.stageNumber < current.stageNumber); if (!candidates.length) return { reason: 'This animation has no previous stage.' }; const previous = Math.max(...candidates.map((stage) => stage.stageNumber)); const playback = stages.find((stage) => stage.stage === previous)?.use_stage ?? previous; const matches = Animation.all.filter((animation) => { const stage = parseAfwStageAnimationName(animation); return stage?.id === current.id && stage.stageNumber === playback; }); if (matches.length !== 1) return { reason: matches.length ? 'The previous stage has duplicate animations.' : `Previous stage p${previous} uses missing animation p${playback}.` }; if (matches[0] === Animation.selected) return { reason: 'The previous stage reuses the current animation.' }; return { source: matches[0], previous }; } function refreshPreviousStagePoseButton() { if (!previousStagePoseAction) return; const { reason } = previousStagePoseSource(); const description = reason || 'Copy the final transforms of all actors from the previous stage into time 0. Existing time-0 transforms are replaced.'; previousStagePoseAction.description = description; for (const node of previousStagePoseAction.nodes || []) { node.classList.toggle('disabled', !!reason); node.setAttribute('aria-disabled', String(!!reason)); const label = node.querySelector('.tooltip_description'); if (label) label.textContent = description; } } function copyPreviousStagePose() { const { source, reason } = previousStagePoseSource(); if (reason) { Blockbench.showQuickMessage(reason, 2500); return; } const target = Animation.selected, poses = []; const originalTime = Timeline.time; try { Timeline.time = source.length; for (const group of Group.all) { if (readActorIndexFromName(group.name) === null || isBlockRequirementGuide(group)) continue; const animator = source.animators[group.uuid]; Animator.resetLastValues(); for (const channel of ['position', 'rotation', 'scale']) { const value = animator?.[channel]?.length ? animator.interpolate(channel, false) : null; const values = value || (channel === 'scale' ? [1, 1, 1] : [0, 0, 0]); if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error(`Cannot evaluate ${group.name} ${channel} at the previous stage endpoint.`); poses.push({ group, channel, values }); } } } finally { Timeline.time = originalTime; Animator.resetLastValues(); } if (!poses.length) { Blockbench.showQuickMessage('There are no actor bones to copy.', 2500); return; } const keyframes = poses.flatMap(({ group, channel }) => (target.animators[group.uuid]?.[channel] || []).filter((frame) => frame.time === 0)); Undo.initEdit({ animations: [target], keyframes }); for (const { group, channel, values } of poses) { const animator = target.getBoneAnimator(group); const frame = animator[channel].find((frame) => frame.time === 0); const data = { interpolation: 'linear', uniform: false, data_points: [{ x: values[0], y: values[1], z: values[2] }] }; if (frame) frame.extend(data); else keyframes.push(animator.createKeyframe(data, 0, channel, false, false)); } Undo.finishEdit('Copy previous stage pose', { animations: [target], keyframes }); Animator.preview(); if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection(); Blockbench.showQuickMessage('Copied previous stage pose to time 0.', 2500); } function cleanSoundPreviewFields(clip) { for (const cues of Object.values(clip?.sound_effects || {})) { for (const cue of Array.isArray(cues) ? cues : [cues]) { if (!cue || typeof cue !== 'object') continue; delete cue.file; delete cue[SOUND_PREVIEW_PROPERTY]; } } return clip; } 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: cleanSoundPreviewFields(stripBlockRequirementGuideBones(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: cleanSoundPreviewFields(stripBlockRequirementGuideBones(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); exportAnimationOutputs( 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.injects_into?.length === 1) actor.injects_into = metadata.injects_into[0]; else if (metadata.injects_into?.length) actor.injects_into = metadata.injects_into; if (metadata.held_item) actor.held_item = metadata.held_item; 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 useStage = parseOptionalNumber( result?.[`${base}_use_stage`], `Stage p${stage.stageNumber} playback stage`, (value) => Number.isInteger(value) && value > 0 ); const escapable = String(result?.[`${base}_escapable`] ?? String(!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 (useStage !== null && useStage !== stage.stageNumber) output.use_stage = useStage; if (speed !== null) output.speed = speed; if (peaked) output.non_peak = true; if (escapable !== 'default') output.escapable = escapable === 'true'; if (stageSeconds !== null) output.stage_seconds = stageSeconds; if (durationMultiplier !== null) output.stage_duration_multiplier = durationMultiplier; return output; } function saveAnimationDefinitionFields(group, definition, actorMetadata) { const undo = typeof Undo !== 'undefined'; if (undo) Undo.initEdit({ animdef_edit: true, actor_preferences_edit: true }); const mergedMetadata = { ...getProjectActorAnimdefMetadata(), ...actorMetadata }; setProjectActorAnimdefMetadata(mergedMetadata); setProjectAnimdefAnimators(definition.animators || []); saveAnimdef(group.id, definition); if (undo) Undo.finishEdit('Save animation definition'); mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); } function exportAnimationDefinitionStub(group, definition, actorMetadata) { saveAnimationDefinitionFields(group, definition, actorMetadata); 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 animdef will omit entity_types for these actors, which makes them wildcard constraints. Add the correct entity_types before using this animation definition.', buttons: ['Cancel', 'Export Animdef'], }, (button) => { if (button === 1 || button === 'Export Animdef') onContinue(); } ); } const DEFINITION_GENERATOR_KEYS = new Set(['display_name', 'animators', 'system_tags', 'animation_tags', 'content_tags', 'required_union_tags', 'join_after', 'match_content_on_join', 'weight', 'attack_eligible', 'liquid_gain_multiplier', 'speed', 'water', 'position_anchor_actor', 'camera', 'manual_peak', 'tease', 'stuck', 'actors', 'stages', 'block_requirements']); const ACTOR_GENERATOR_KEYS = new Set(['label', 'entity_types', 'entity_variant', 'actor_tags', 'actor_tags_any', 'activity', 'injector', 'receiver', 'injects_into', 'held_item']); const STAGE_GENERATOR_KEYS = new Set(['stage', 'loop', 'cycle_seconds', 'allow_join', 'escapable', 'non_peak', 'speed', 'use_stage', 'stage_seconds', 'stage_duration_multiplier', 'cycle_midpoint_offset_seconds']); function unmanagedAnimdefFields(source, keys) { return Object.fromEntries(Object.entries(source || {}).filter(([key]) => !keys.has(key))); } function prepareAnimationDefinitionImport(source, analysis) { const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); if (!isObject(source) || !Array.isArray(source.actors) || !source.actors.length || !Array.isArray(source.stages) || !source.stages.length) { throw new Error('Expected an animation definition JSON with non-empty actors and stages arrays (not an animation asset).'); } const definition = clone(source); for (const key of ['animators', 'system_tags', 'animation_tags', 'content_tags', 'required_union_tags', 'join_after']) { if (definition[key] !== undefined && (!Array.isArray(definition[key]) || definition[key].some((value) => typeof value !== 'string'))) { throw new Error(`${key} must be an array of strings.`); } } for (const key of ['camera', 'manual_peak', 'tease', 'stuck', 'block_requirements']) { if (definition[key] !== undefined && !isObject(definition[key])) throw new Error(`${key} must be an object.`); } if (definition.system_tags === undefined && definition.animation_tags !== undefined) { definition.system_tags = definition.animation_tags; } delete definition.animation_tags; if (definition.actors.length !== analysis.actorIndices.length) { throw new Error(`The definition has ${definition.actors.length} actors, but this animation has ${analysis.actorIndices.length}. Select an animation with the same actors.`); } const stageNumbers = new Set(analysis.stages.map((stage) => stage.stageNumber)); const seenStages = new Set(); for (const stage of definition.stages) { if (!isObject(stage) || !Number.isInteger(stage.stage) || stage.stage < 1 || seenStages.has(stage.stage)) { throw new Error('Imported stages must have unique stage numbers matching the target animation.'); } const playback = stage.use_stage ?? stage.stage; if (!Number.isInteger(playback) || !stageNumbers.has(playback)) { throw new Error(`Stage p${stage.stage} requires animation clips for playback stage p${playback}.`); } seenStages.add(stage.stage); } if ([...stageNumbers].some((number) => !seenStages.has(number))) throw new Error('The definition must include every stage of the target animation.'); const roles = analysis.actorIndices.map((index) => analysis.actorLabels.get(index)); const labels = new Set(); for (const actor of definition.actors) { if (!isObject(actor)) throw new Error('Every actor must be an object.'); if (actor.label !== undefined && (typeof actor.label !== 'string' || !actor.label.trim() || labels.has(actor.label))) { throw new Error('Explicit actor labels must be non-empty, unique strings.'); } if (actor.label) labels.add(actor.label); for (const key of ['entity_types', 'actor_tags', 'actor_tags_any']) { if (actor[key] !== undefined && (!Array.isArray(actor[key]) || actor[key].some((value) => typeof value !== 'string'))) { throw new Error(`Actor ${key} must be an array of strings.`); } } if (actor.held_item !== undefined && !isObject(actor.held_item)) throw new Error('Actor held_item must be an object.'); } // Reserve exact matches first; unmatched or unlabeled actors use remaining roles in order. const assigned = definition.actors.map((actor) => roles.includes(actor.label) ? actor.label : null); const available = roles.filter((role) => !assigned.includes(role)); const labelMap = new Map(); definition.actors.forEach((actor, index) => { assigned[index] ||= available.shift(); labelMap.set(actor.label || `actor${index + 1}`, assigned[index]); }); definition.actors.forEach((actor, index) => { actor.label = assigned[index]; if (actor.injects_into !== undefined) { const targets = typeof actor.injects_into === 'string' ? [actor.injects_into] : actor.injects_into; if (!Array.isArray(targets) || targets.some((target) => !labelMap.has(target))) { throw new Error(`Actor ${actor.label} references an unknown injection target.`); } actor.injects_into = targets.map((target) => labelMap.get(target)); if (actor.injects_into.includes(actor.label)) throw new Error('An actor cannot inject into itself.'); } }); definition.actors.sort((a, b) => roles.indexOf(a.label) - roles.indexOf(b.label)); for (const stage of definition.stages) { if (stage.props && typeof stage.props === 'object' && !Array.isArray(stage.props)) { stage.props = Object.fromEntries(Object.entries(stage.props).map(([label, value]) => [labelMap.get(label) || label, value])); } } if (definition.position_anchor_actor !== undefined) { if (!labelMap.has(definition.position_anchor_actor)) throw new Error('The position anchor references an unknown actor.'); definition.position_anchor_actor = labelMap.get(definition.position_anchor_actor); } return definition; } function applyAnimationDefinitionImport(group, analysis, source, sourcePaths = []) { const definition = prepareAnimationDefinitionImport(source, analysis); let entry = null; if (definition.block_requirements) { const req = definition.block_requirements; if (!['wall', 'center_support'].includes(req.type) || (req.facing && !['front', 'back', 'left', 'right'].includes(req.facing)) || (req.support && !['full_block', 'slab', 'half_height', 'surface'].includes(req.support)) || (req.placement && !['directional_clearance', 'surface_footprint'].includes(req.placement))) { throw new Error('The block requirement has an unsupported type, support, placement or facing.'); } const form = buildBlockRequirementForm(group.id, req); const values = Object.fromEntries(Object.entries(form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value])); entry = readBlockRequirementForm(values); buildBlockRequirementGuideBoxes(entry); } const props = importPropPreviews(group.id, definition, sourcePaths, readPropPreviews()); cancelBlockRequirementUpdate(); rebuildBlockRequirementGuides(group.id, entry, definition, true, props.entries); mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); if (props.warnings.length) Blockbench.showQuickMessage(`Item props: ${props.warnings.join(' ')}`, 6000); void refreshPropPreviews(true); if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id); showAnimationDefinitionReviewDialog(group, analysis); } function importAnimationDefinition() { if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500); const projectId = Project.uuid; Filesystem.importFile({ type: 'AFW Animation Definition', extensions: ['json'], multiple: false, readtype: 'text', title: 'Import Animation Definition' }, (files) => { if (!files?.length) return; const report = (error) => Blockbench.showMessageBox({ title: 'Import Animation Definition', message: error.message, buttons: ['OK'] }); try { if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.'); const source = JSON.parse(String(files[0].content || '').replace(/^\uFEFF/, '')); const groups = collectConjoinedAnimationGroups().groups; if (!groups.length) throw new Error('Import your animation assets first (named .p), then import their definition.'); const options = Object.fromEntries(groups.map((group) => [group.id, group.id])); const fileId = String(files[0].name || files[0].path || '').split(/[\\/]/).pop().replace(/\.json$/i, ''); const selected = parseAfwStageAnimationName(getSelectedAnimation()); new Dialog({ id: 'animationdirector_import_animdef', title: 'Import Animation Definition', width: 520, form: { _info: { type: 'info', text: 'Choose the target animation. This replaces its saved definition and block guides. Actor labels are matched first; remaining actors are assigned in order. Keyframes and models are unchanged.' }, animation_id: { label: 'Animation', type: 'select', options, value: options[fileId] ? fileId : options[selected?.id] ? selected.id : groups[0].id }, }, onConfirm(result) { try { if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.'); const group = collectConjoinedAnimationGroups().groups.find((candidate) => candidate.id === result.animation_id); if (!group) throw new Error('The selected animation no longer exists.'); applyAnimationDefinitionImport(group, analyzeAnimationDefinitionGroup(group), source, [files[0].path]); return true; } catch (e) { report(e); return false; } }, }).show(); } catch (e) { report(e); } }); } function showAnimationDefinitionReviewDialog(group, analysis) { const savedMetadata = getProjectActorAnimdefMetadata(); const saved = getSavedAnimdef(group.id); const savedActors = new Map((Array.isArray(saved.actors) ? saved.actors : []).map((actor) => [actor.label, actor])); const savedStages = new Map((Array.isArray(saved.stages) ? saved.stages : []).map((stage) => [stage.stage, stage])); const editableStages = new Map(analysis.stages.map((stage) => [stage.stageNumber, stage])); const reusedStages = [...savedStages.values()].filter((stage) => !editableStages.has(stage.stage) && editableStages.has(stage.use_stage)).map((stage) => ({ ...editableStages.get(stage.use_stage), stageNumber: stage.stage, loop: stage.loop ?? editableStages.get(stage.use_stage).loop })); analysis = { ...analysis, stages: [...analysis.stages, ...reusedStages].sort((a, b) => a.stageNumber - b.stageNumber) }; const savedSystemTags = uniqueTrimmedStrings((Array.isArray(saved.system_tags) ? saved.system_tags : []) .map((tag) => String(tag).toLowerCase())); const savedTag = savedSystemTags[0] || ''; const selectedSystemTag = Object.hasOwn(SYSTEM_TAG_OPTIONS, savedTag) ? savedTag : 'other'; const form = { _info: { type: 'info', text: `Creating ${group.id}.json with ${analysis.actorIndices.length} actor(s) and ${analysis.stages.length} stage(s). ` + 'Save stores your fields in this Blockbench project. Create Animdef also exports JSON for data//afw_animdefs/.', }, }; if (analysis.warnings.length) { form._warnings = { type: 'info', text: `Warnings: ${analysis.warnings.join(' ')}`, }; } if (savedSystemTags.length > 1) { form._system_tag_warning = { type: 'info', text: `This saved definition has multiple system tags (${savedSystemTags.join(', ')}). ` + 'The generator now exports only the selected behavior, so review that choice before exporting.', }; } form._identity_separator = '_'; form._identity = { type: 'info', text: '### Identity and credits' }; form.display_name = { label: 'Display Name', type: 'text', value: saved.display_name || '', placeholder: formatAnimationDisplayName(group.id), description: 'Optional player-facing name. AFW uses the animation ID when this is empty.', }; form.animators = { label: 'Animators', type: 'text', value: (saved.animators || (Object.keys(saved).length ? [] : getProjectAnimdefAnimators())).join(', '), placeholder: 'Animator One, Animator Two', description: 'Optional comma-separated individual animation credits. Saved in this Blockbench project.', }; form._matching_separator = '_'; form._matching = { type: 'info', text: '### Matching & behavior' }; form.content_tags = { label: 'Content tags', type: 'text', value: (saved.content_tags || []).join(', '), placeholder: 'doggy, missionary', description: 'Displayed to players and used for matching defeated or stuck follow-ups.', }; form.liquid_gain_multiplier = { label: 'Liquid gain multiplier', type: 'text', value: saved.liquid_gain_multiplier ?? '', placeholder: 'Default (1.0)', description: 'Non-negative multiplier for liquid gained from this animation.', }; form.attack_eligible = { label: 'Attack eligible', type: 'checkbox', value: saved.attack_eligible !== false, description: 'Turn off to exclude this animation from automatic mob attacks.', }; const hasSystemTag = (result, tag) => result?.system_tags === tag; const isTease = (result) => hasSystemTag(result, 'tease'); const isStuck = (result) => hasSystemTag(result, 'stuck'); form.system_tags = { label: 'Behavior', type: 'select', options: SYSTEM_TAG_OPTIONS, value: selectedSystemTag, description: 'Choose one system tag. Tease and Stuck reveal their additional settings.', }; form.other_system_tags = { label: 'Other system tag', type: 'text', value: selectedSystemTag === 'other' ? savedTag : '', placeholder: 'custom_tag', description: 'One custom tag, included only while Other is selected.', condition: (result) => hasSystemTag(result, 'other'), }; form.stub_tease_multiplier = { label: 'Nearby energy multiplier', type: 'number', value: saved.tease?.near_animation_energy_multiplier ?? 2, min: 1, max: 20, step: 0.1, condition: isTease, }; form.manual_peak_priority = { label: 'Manual peak priority', type: 'text', value: saved.manual_peak?.priority ?? '', placeholder: 'Default (0)', description: 'Only used when the behavior is Manual peak. Higher values are selected first.', condition: (result) => hasSystemTag(result, 'manualpeak'), }; form.stub_stuck_initial_difficulty = { label: 'Initial escape difficulty', type: 'number', value: saved.stuck?.initial_escape_difficulty_multiplier ?? 2, min: 0.1, max: 10, step: 0.05, condition: isStuck, }; form.stub_stuck_final_difficulty = { label: 'Final escape difficulty', type: 'number', value: saved.stuck?.final_escape_difficulty_multiplier ?? 0.75, min: 0.1, max: 10, step: 0.05, condition: isStuck, }; form.stub_stuck_ease_delay = { label: 'Ease delay seconds', type: 'number', value: saved.stuck?.ease_delay_seconds ?? 10, min: 0, max: 600, step: 1, condition: isStuck, }; form.stub_stuck_ease_duration = { label: 'Ease duration seconds', type: 'number', value: saved.stuck?.ease_duration_seconds ?? 30, min: 0, max: 600, step: 1, condition: isStuck, }; form.stub_stuck_joined_difficulty = { label: 'Joined escape difficulty', type: 'number', value: saved.stuck?.joined_escape_difficulty_multiplier ?? 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: saved.stuck?.damage_behavior || 'stop_on_damage', condition: isStuck, }; const showAdvancedMatching = !!(saved.required_union_tags?.length || saved.join_after?.length || saved.match_content_on_join || (saved.weight != null && saved.weight !== 1)); form.matching_advanced = { label: 'Advanced', type: 'checkbox', style: 'toggle_switch', value: showAdvancedMatching, description: 'Show additional matching and selection rules.', }; const advancedMatching = (result) => result?.matching_advanced ?? showAdvancedMatching; form.required_union_tags = { label: 'Required union tags', type: 'text', value: (saved.required_union_tags || []).join(', '), placeholder: 'some.tag', description: 'All tags must exist among the selected actors combined.', condition: advancedMatching, }; form.join_after = { label: 'Join after', type: 'text', value: (saved.join_after || []).join(', '), placeholder: 'creator:previous_animation', description: 'Optional comma-separated exact namespaced animation IDs.', condition: advancedMatching, }; form.match_content_on_join = { label: 'Match content on join', type: 'checkbox', value: saved.match_content_on_join === true, description: 'Successors joining this animation must share at least one content tag.', condition: advancedMatching, }; form.weight = { label: 'Selection weight', type: 'text', value: saved.weight ?? '', placeholder: 'Default (1.0)', description: 'Positive random-selection weight among otherwise eligible animations.', condition: advancedMatching, }; form._placement_separator = '_'; form._placement = { type: 'info', text: '### Playback and placement' }; const authoredRequirement = getAuthoredBlockRequirement(group.id); const blockRequirement = authoredRequirement ? authoredRequirement.requirement : saved.block_requirements; form._block_requirement = { type: 'info', text: blockRequirement ? `#### Block requirement\nIncluded automatically:\n\n\`\`\`json\n${JSON.stringify(blockRequirement, null, 2)}\n\`\`\`\nEdit using Tools > AnimationDirector Anims > Block Requirements.` : '#### Block requirement\nNone. Use Tools > AnimationDirector Anims > Block Requirements to create one.', }; form.base_speed = { label: 'Base playback speed', type: 'text', value: saved.speed ?? '', placeholder: 'Default (1.0)', description: 'Positive speed used unless an individual stage overrides it.', }; form.water = { label: 'Water placement', type: 'select', value: saved.water || 'none', options: { none: 'No water requirement', surface: 'Water surface', underwater: 'Underwater' }, }; const anchorOptions = { '': 'Automatic anchor' }; for (const actorIndex of analysis.actorIndices) { const label = analysis.actorLabels.get(actorIndex); anchorOptions[label] = label; } form.position_anchor_actor = { label: 'Position anchor actor', type: 'select', value: saved.position_anchor_actor || '', options: anchorOptions, description: 'Prefer this actor as the animation placement anchor.', }; form.camera_torso_tracking = { label: 'Camera torso tracking', type: 'select', value: saved.camera?.torso_tracking || 'adaptive', options: { adaptive: 'Adaptive (default)', continuous: 'Continuous' }, description: 'Continuous tracking follows the animated torso during each stage.', }; form.camera_block_collision = { label: 'Camera block collision', type: 'select', value: saved.camera?.block_requirement_collision || 'normal', options: { normal: 'Normal (default)', ignore: 'Ignore required blocks' }, description: 'Ignore collision only with the blocks used by a block requirement.', }; form._actors_separator = '_'; form._actors = { type: 'info', text: '### Actor constraints' }; analysis.actorIndices.forEach((actorIndex, actorPosition) => { const label = analysis.actorLabels.get(actorIndex); const savedActor = savedActors.get(label); const metadata = savedActor ? cleanActorAnimdefMetadata({ ...savedActor, injects_into: typeof savedActor.injects_into === 'string' ? [savedActor.injects_into] : savedActor.injects_into }) : actorAnimdefMetadataWithInference(actorIndex, savedMetadata[String(actorIndex)]); addActorAnimdefFormFields( form, 'stub_actor', actorIndex, label, metadata, actorPosition === 0 ? 'active' : 'passive', undefined, analysis.actorIndices.map((index) => analysis.actorLabels.get(index)) ); }); form._stages_separator = '_'; 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 savedStage = savedStages.get(stage.stageNumber) || {}; const stageLoop = savedStage.loop ?? stage.loop; const showAdvanced = (result) => !!result?.[`${base}_advanced`]; const showAdvancedLoop = (result) => showAdvanced(result) && (result?.[`${base}_loop`] ?? stageLoop); form[`${base}_heading`] = { type: 'info', text: `#### Stage p${stage.stageNumber}\nDetected ${stage.loop ? 'looping' : 'non-looping'}, ${stage.cycleSeconds}s`, }; form[`${base}_loop`] = { label: 'Loop', type: 'checkbox', value: stageLoop, }; form[`${base}_cycle_seconds`] = { label: 'Cycle seconds', type: 'number', value: savedStage.cycle_seconds ?? 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: savedStage.allow_join ?? !isLastStage, }; form[`${base}_escapable`] = { label: 'Escapable', type: 'select', options: { default: 'Use top-level default', true: 'Yes', false: 'No' }, value: savedStage.escapable == null ? (savedStages.has(stage.stageNumber) ? 'default' : isLastStage ? 'false' : 'default') : String(savedStage.escapable), }; form[`${base}_peaked`] = { label: 'Peaked stage', type: 'checkbox', value: savedStages.has(stage.stageNumber) ? savedStage.non_peak === true : isLastStage, description: "Marks this as NoN's peaked stage.", }; form[`${base}_advanced`] = { label: 'Advanced stage settings', type: 'checkbox', style: 'toggle_switch', value: !!(savedStage.speed || savedStage.use_stage || savedStage.stage_seconds || savedStage.cycle_midpoint_offset_seconds || savedStage.stage_duration_multiplier), description: 'Show optional playback and duration controls.', }; form[`${base}_speed`] = { label: 'Playback speed', type: 'text', value: savedStage.speed ?? '', placeholder: 'Default (1.0)', condition: showAdvanced, }; form[`${base}_use_stage`] = { label: 'Play animation from stage', type: 'select', options: Object.fromEntries([ ['', `Own stage (p${stage.stageNumber})`], ...analysis.stages.map((availableStage) => [ String(availableStage.stageNumber), `Stage p${availableStage.stageNumber}`, ]), ]), value: savedStage.use_stage == null ? '' : String(savedStage.use_stage), description: 'Optional stage number whose GeckoLib clip should be played instead.', condition: showAdvanced, }; form[`${base}_stage_seconds`] = { label: 'Stage seconds', type: 'text', value: savedStage.stage_seconds ?? '', placeholder: 'Use NoN settings', condition: showAdvanced, }; form[`${base}_midpoint_offset`] = { label: 'Cycle midpoint offset seconds', type: 'text', value: savedStage.cycle_midpoint_offset_seconds ?? '', placeholder: 'Default (0.0)', condition: showAdvancedLoop, }; form[`${base}_duration_multiplier`] = { label: 'Stage duration multiplier', type: 'text', value: savedStage.stage_duration_multiplier ?? '', placeholder: 'Default (1.0)', condition: showAdvancedLoop, }; }); const submit = (result, exportFile) => { 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({ ...unmanagedAnimdefFields(savedActors.get(label), ACTOR_GENERATOR_KEYS), ...buildActorConstraint(label, metadata) }); } const stages = analysis.stages.map((stage, index) => ({ ...unmanagedAnimdefFields(savedStages.get(stage.stageNumber), STAGE_GENERATOR_KEYS), ...readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1), })); const actorsByLabel = new Map(actors.map((actor) => [actor.label, actor])); const explicitReceivers = actors.some((actor) => actor.receiver); const inferredReceivers = actors.some((actor) => !actor.injector); for (const actor of actors) { const targets = typeof actor.injects_into === 'string' ? [actor.injects_into] : actor.injects_into || []; for (const target of targets) { const receiver = actorsByLabel.get(target); if (!receiver || target === actor.label) { throw new Error(`Actor "${actor.label}" has invalid injection target "${target}".`); } const canReceive = explicitReceivers ? receiver.receiver : inferredReceivers ? !receiver.injector : receiver.entity_types?.includes('minecraft:player'); if (!canReceive) throw new Error(`Actor "${target}" is not a receiver for "${actor.label}".`); } } const stageNumbers = new Set(stages.map((stage) => stage.stage)); for (const stage of stages) { if (stage.use_stage && !stageNumbers.has(stage.use_stage)) { throw new Error(`Stage p${stage.stage} references missing playback stage p${stage.use_stage}.`); } } const displayName = String(result?.display_name || '').trim(); const animators = normalizeAnimatorNames(String(result?.animators || '').split(',')); const definition = unmanagedAnimdefFields(saved, DEFINITION_GENERATOR_KEYS); const authored = getAuthoredBlockRequirement(group.id); const requirement = authored ? authored.requirement : saved.block_requirements; if (requirement) definition.block_requirements = clone(requirement); if (displayName) definition.display_name = displayName; if (animators.length) definition.animators = animators; const selectedTag = String(result?.system_tags || ''); const customTag = String(result?.other_system_tags || '').trim().toLowerCase(); if (selectedTag === 'other' && (!customTag || customTag.includes(','))) { throw new Error('Enter one custom system tag or choose a different behavior.'); } const systemTags = selectedTag === 'other' ? [customTag] : selectedTag ? [selectedTag] : []; const contentTags = uniqueTrimmedStrings(parseCommaSeparatedStrings(result?.content_tags) .map((tag) => tag.toLowerCase())); const unionTags = parseCommaSeparatedStrings(result?.required_union_tags); const joinAfter = parseCommaSeparatedStrings(result?.join_after); for (const id of joinAfter) { if (!RESOURCE_ID_PATTERN.test(id)) { throw new Error(`Invalid join_after animation ID "${id}". Use namespace:path.`); } } if (systemTags.length) definition.system_tags = systemTags; if (contentTags.length) definition.content_tags = contentTags; if (unionTags.length) definition.required_union_tags = unionTags; if (joinAfter.length) definition.join_after = joinAfter; if (result?.match_content_on_join) { if (!contentTags.length) throw new Error('Match content on join requires at least one content tag.'); definition.match_content_on_join = true; } const weight = parseOptionalNumber(result?.weight, 'Selection weight', (value) => value > 0); if (weight !== null && weight !== 1) definition.weight = weight; if (result?.attack_eligible === false) definition.attack_eligible = false; const baseSpeed = parseOptionalNumber(result?.base_speed, 'Base playback speed', (value) => value > 0); if (baseSpeed !== null && baseSpeed !== 1) definition.speed = baseSpeed; const water = String(result?.water || 'none'); if (water !== 'none') definition.water = water; const anchor = String(result?.position_anchor_actor || ''); if (anchor) definition.position_anchor_actor = anchor; const camera = unmanagedAnimdefFields(saved.camera, new Set(['torso_tracking', 'block_requirement_collision'])); if (result?.camera_torso_tracking === 'continuous') camera.torso_tracking = 'continuous'; if (result?.camera_block_collision === 'ignore') camera.block_requirement_collision = 'ignore'; if (Object.keys(camera).length) definition.camera = camera; const liquidGain = parseOptionalNumber(result?.liquid_gain_multiplier, 'Liquid gain multiplier', (value) => value >= 0); if (liquidGain !== null && liquidGain !== 1) definition.liquid_gain_multiplier = liquidGain; if (systemTags.includes('manualpeak')) { const priority = parseOptionalNumber(result?.manual_peak_priority, 'Manual peak priority', Number.isInteger); const manualPeak = unmanagedAnimdefFields(saved.manual_peak, new Set(['priority'])); if (priority !== null && priority !== 0) manualPeak.priority = priority; if (Object.keys(manualPeak).length) definition.manual_peak = manualPeak; } if (systemTags.includes('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.tease = { ...saved.tease, near_animation_energy_multiplier: multiplier }; } if (systemTags.includes('stuck')) { if (!contentTags.length) { throw new Error('A stuck animation needs at least one matching content tag.'); } if (!stages.some((stage) => stage.escapable ?? true)) { 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.stuck = { ...saved.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; if (exportFile) { confirmUnresolvedActorTypes(unresolvedLabels, () => exportAnimationDefinitionStub(group, definition, actorMetadata) ); } else { saveAnimationDefinitionFields(group, definition, actorMetadata); Blockbench.showQuickMessage(`Saved animation definition fields for ${group.id}.`); } return true; } catch (e) { Blockbench.showMessageBox({ title: 'Create Animation Definition', message: e?.message || String(e), buttons: ['OK'], }); return false; } }; const dialog = new Dialog({ id: 'create_afw_animation_definition_review', title: `Create Animation Definition: ${group.id}`, width: 760, form, buttons: ['Cancel', 'Save', 'Create Animdef'], cancelIndex: 0, confirmIndex: 2, onConfirm: (result) => submit(result, true), onButton(index) { if (index === 1) { submit(dialog.getFormResult(), false); 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', 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 hasActorPrefix(name) { return readActorIndexFromName(name) !== null; } function addPrefixIfMissing(name, actorIndex) { return hasActorPrefix(name) ? name : `actor${actorIndex}_${name}`; } function prefixAnimationObject(animObj, actorIndex) { const a = clone(animObj || {}); const bones = a?.bones && typeof a.bones === 'object' ? a.bones : null; if (!bones) throw new Error('Animation has no "bones" map.'); const newBones = {}; for (const boneName of Object.keys(bones)) { const newName = addPrefixIfMissing(boneName, actorIndex); if (newBones[newName]) throw new Error(`Bone collision inside one file: "${newName}"`); newBones[newName] = bones[boneName]; } a.bones = newBones; const fixStrings = (node) => { if (!node) return; if (Array.isArray(node)) { for (const v of node) fixStrings(v); return; } if (typeof node !== 'object') return; for (const k of Object.keys(node)) { const v = node[k]; if (typeof v === 'string' && (k === 'bone' || k === 'locator')) { node[k] = addPrefixIfMissing(v, actorIndex); } else { fixStrings(v); } } }; fixStrings(a); return a; } function parseConjoinedStageActorKey(key) { const match = /^p([1-9]\d*)_(.+)$/.exec(String(key || '').trim()); if (!match || !/^[a-z0-9._-]+$/.test(match[2])) return null; return { stageNumber: Number(match[1]), actorLabel: match[2], }; } function conjoinedAnimationIdFromFile(file) { const rawName = String(file?.name || file?.path || '').trim(); const fileName = rawName.split(/[\\/]/).pop() || ''; const match = /^([a-z0-9._-]+)\.animation\.json$/.exec(fileName); if (!match) { throw new Error( `Conjoined animation filename "${fileName || '(unknown)'}" must use .animation.json.` ); } return match[1]; } function parseConjoinedAnimationFile(file) { const text = file?.content; if (typeof text !== 'string' || !text.trim()) { throw new Error(`Could not read file contents: ${file?.name || '(unknown)'}`); } const json = JSON.parse(text); const animations = json?.animations; if (!animations || typeof animations !== 'object' || Array.isArray(animations)) { throw new Error(`Missing "animations" map in ${file?.name || '(unknown)'}.`); } const animationId = conjoinedAnimationIdFromFile(file); const stagesByNumber = new Map(); const actorLabels = []; const seenActorLabels = new Set(); for (const [key, animObj] of Object.entries(animations)) { const parsedKey = parseConjoinedStageActorKey(key); if (!parsedKey) { throw new Error( `Animation key "${key}" does not use the expected p_ format.` ); } if (!animObj || typeof animObj !== 'object' || Array.isArray(animObj)) { throw new Error(`Invalid animation object for "${key}".`); } const stageNumber = parsedKey.stageNumber; const actorLabel = parsedKey.actorLabel; if (!stagesByNumber.has(stageNumber)) { stagesByNumber.set(stageNumber, { stageNumber, entries: [] }); } const stage = stagesByNumber.get(stageNumber); if (stage.entries.some((entry) => entry.actorLabel === actorLabel)) { throw new Error(`Stage p${stageNumber} contains actor "${actorLabel}" more than once.`); } stage.entries.push({ key, actorLabel, animObj }); if (!seenActorLabels.has(actorLabel)) { seenActorLabels.add(actorLabel); actorLabels.push(actorLabel); } } if (!stagesByNumber.size) { throw new Error(`No conjoined animation clips found in ${file?.name || '(unknown)'}.`); } return { file, animationId, formatVersion: json?.format_version || '1.8.0', actorLabels, stages: Array.from(stagesByNumber.values()).sort((a, b) => a.stageNumber - b.stageNumber), }; } function stableJsonString(value) { if (Array.isArray(value)) return `[${value.map(stableJsonString).join(',')}]`; if (value && typeof value === 'object') { return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${stableJsonString(value[key])}`) .join(',')}}`; } return JSON.stringify(value); } function animationClipSettings(clip) { const settings = clone(clip || {}); delete settings.bones; delete settings.sound_effects; delete settings.particle_effects; return settings; } function mergeCuePayload(existing, incoming) { const merged = []; const seen = new Set(); const add = (payload) => { const values = Array.isArray(payload) ? payload : [payload]; for (const value of values) { const signature = stableJsonString(value); if (seen.has(signature)) continue; seen.add(signature); merged.push(clone(value)); } }; add(existing); add(incoming); return merged.length === 1 ? merged[0] : merged; } function mergeTimedEffectMap(target, source, fieldName, stageName) { if (source == null) return; if (typeof source !== 'object' || Array.isArray(source)) { throw new Error(`Stage "${stageName}" has an invalid "${fieldName}" map.`); } if (!target[fieldName]) target[fieldName] = {}; for (const [cueTime, payload] of Object.entries(source)) { if (!Object.prototype.hasOwnProperty.call(target[fieldName], cueTime)) { target[fieldName][cueTime] = clone(payload); } else { target[fieldName][cueTime] = mergeCuePayload(target[fieldName][cueTime], payload); } } } function mergeConjoinedStage(stage, actorMapping, outputStageName) { let combined = null; let expectedSettings = null; for (const entry of stage.entries) { const actorIndex = actorMapping.get(entry.actorLabel); if (!actorIndex) throw new Error(`No project actor selected for "${entry.actorLabel}".`); const clip = clone(entry.animObj); if (clip.bones == null) clip.bones = {}; if (typeof clip.bones !== 'object' || Array.isArray(clip.bones)) { throw new Error(`Animation "${entry.key}" has an invalid "bones" map.`); } const prefixed = prefixAnimationObject(clip, actorIndex); const settings = animationClipSettings(prefixed); if (expectedSettings === null) { expectedSettings = settings; combined = clone(settings); combined.bones = {}; } else if (stableJsonString(settings) !== stableJsonString(expectedSettings)) { throw new Error( `Actor clips in stage "${outputStageName}" disagree on loop, length, or other stage-wide settings.` ); } for (const [boneName, payload] of Object.entries(prefixed.bones)) { if (Object.prototype.hasOwnProperty.call(combined.bones, boneName)) { throw new Error(`Bone collision in stage "${outputStageName}": "${boneName}".`); } combined.bones[boneName] = payload; } mergeTimedEffectMap(combined, prefixed.sound_effects, 'sound_effects', outputStageName); mergeTimedEffectMap(combined, prefixed.particle_effects, 'particle_effects', outputStageName); } return combined; } function resolveDefinitionImportStages(parsed, definition) { if (!Array.isArray(definition.stages) || !definition.stages.length) { throw new Error('The definition must contain at least one stage.'); } const numbers = new Set(); for (const stage of definition.stages) { if (!stage || !Number.isInteger(stage.stage) || stage.stage < 1 || numbers.has(stage.stage)) { throw new Error('Definition stages must have unique positive stage numbers.'); } numbers.add(stage.stage); } const clips = new Map(parsed.stages.map((stage) => [stage.stageNumber, stage])); for (const stage of definition.stages) { const playback = stage.use_stage ?? stage.stage; if (!Number.isInteger(playback) || !numbers.has(playback)) { throw new Error(`Stage p${stage.stage} references missing playback stage p${playback}.`); } // NoN references the target's clips directly, not its own use_stage setting. const source = clips.get(playback); if (!source) throw new Error(`Stage p${stage.stage} requires animation clips for playback stage p${playback}.`); } // Reused stages remain definition metadata, not duplicate editable animations. return definition.stages.filter((stage) => stage.use_stage == null || stage.use_stage === stage.stage) .map((stage) => clips.get(stage.stage)).sort((a, b) => a.stageNumber - b.stageNumber); } function buildConjoinedImportFile(parsed, outputAnimationId, actorMapping) { const animations = {}; const stageNames = []; for (const stage of parsed.stages) { const stageName = `${outputAnimationId}.p${stage.stageNumber}`; stageNames.push(stageName); animations[stageName] = mergeConjoinedStage(stage, actorMapping, stageName); } return { fileObj: { format_version: parsed.formatVersion, animations, }, stageNames, }; } function defaultActorOptionForLabel(actorLabel, actorOptions) { for (const key of Object.keys(actorOptions)) { if (getActorExportLabel(Number(key)) === actorLabel) return key; } const actorMatch = /^actor([1-9]\d*)$/.exec(actorLabel); if (actorMatch && Object.prototype.hasOwnProperty.call(actorOptions, actorMatch[1])) return actorMatch[1]; return Object.keys(actorOptions)[0]; } function importConjoinedAnimation() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); return; } if (typeof Animator?.loadFile !== 'function') { Blockbench.showMessageBox({ title: 'Import Animation', message: 'Animation importer not available in this Blockbench setup (missing Animator.loadFile).', buttons: ['OK'], }); return; } Filesystem.importFile( { type: 'AnimationDirector Conjoined Animation JSON', extensions: ['json'], multiple: false, readtype: 'text', resource_id: 'animation', title: 'Select a conjoined animation file', }, (files) => { if (!files?.length) return; try { const parsed = parseConjoinedAnimationFile(files[0]); const actorOptions = buildActorSelectOptions(); const actorOptionKeys = Object.keys(actorOptions); if (!actorOptionKeys.length) { Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500); return; } const form = { animation_id: { label: 'Animation ID', type: 'text', value: parsed.animationId, placeholder: 'wolfmplayer', }, _info: { type: 'info', text: 'Map each actor stored in the file to an actor in this project. All stages will be imported together.', }, }; for (let i = 0; i < parsed.actorLabels.length; i++) { const actorLabel = parsed.actorLabels[i]; form[`actor_${i}`] = { label: actorLabel, type: 'select', options: actorOptions, value: defaultActorOptionForLabel(actorLabel, actorOptions), }; } const dialog = new Dialog({ id: 'import_conjoined_actor_animation', title: 'Import Animation', width: 620, form, buttons: ['Cancel', 'Import'], cancelIndex: 0, confirmIndex: 1, onConfirm: (result) => { try { const outputAnimationId = String(result?.animation_id || '').trim(); if (!/^[a-z0-9._-]+$/.test(outputAnimationId)) { throw new Error('Animation ID must use only lowercase letters, numbers, ., _ or -.'); } const actorMapping = new Map(); const usedActorIndices = new Set(); for (let i = 0; i < parsed.actorLabels.length; i++) { const actorLabel = parsed.actorLabels[i]; const actorIndex = Number(result?.[`actor_${i}`]); if (!Number.isFinite(actorIndex) || actorIndex <= 0) { throw new Error(`Invalid project actor selected for "${actorLabel}".`); } if (usedActorIndices.has(actorIndex)) { throw new Error('Each file actor must map to a different project actor.'); } actorMapping.set(actorLabel, Math.floor(actorIndex)); usedActorIndices.add(Math.floor(actorIndex)); } const imported = buildConjoinedImportFile(parsed, outputAnimationId, actorMapping); const doImport = () => { const fakeFile = { name: `${outputAnimationId}.animation.json`, path: '', content: JSON.stringify(imported.fileObj, null, 2), }; Animator.loadFile(fakeFile, imported.stageNames); mapAnimationSounds(Animation.all); Blockbench.showQuickMessage( `Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`, 2500 ); }; const existing = imported.stageNames.filter(animationNameExists); if (existing.length) { Blockbench.showMessageBox( { title: 'Import Animation', message: `The following animations already exist:\n\n${existing.map((name) => `- ${name}`).join('\n')}\n\nOverwrite them?`, buttons: ['Cancel', 'Overwrite'], }, (button) => { if (button !== 1 && button !== 'Overwrite') return; for (const name of existing) removeAnimationByName(name); doImport(); } ); } else { doImport(); } return true; } catch (e) { console.error(e); Blockbench.showMessageBox({ title: 'Import Animation', message: `Import failed: ${e?.message || e}`, buttons: ['OK'], }); return false; } }, }); dialog.show(); } catch (e) { console.error(e); Blockbench.showMessageBox({ title: 'Import Animation', message: `Import failed: ${e?.message || e}`, buttons: ['OK'], }); } } ); } function animationNameExists(name) { const list = Animation.all; if (!Array.isArray(list)) return false; for (const a of list) if (a?.name === name) return true; return false; } function removeAnimationByName(name) { const list = Animation.all; if (!Array.isArray(list)) return; const a = list.find((x) => x?.name === name); if (!a) return; if (typeof a.remove === 'function') a.remove(); else if (typeof a.delete === 'function') a.delete(); } // Automatic import reads only explicitly registered, extracted packs. No network access. const PACK_FOLDERS_KEY = 'animationdirector_pack_folders'; const IMPORT_METADATA = [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, BONE_TEXTURE_OVERRIDES_PROPERTY, ACTOR_ANIMDEF_METADATA_PROPERTY, ANIMDEF_ANIMATORS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, BLOCK_REQUIREMENTS_PROPERTY, PROP_PREVIEWS_PROPERTY, ACTOR_UV_BASIS_PROPERTY]; // Item previews are private Three.js attachments, never actor geometry or animation tracks. function propResourcePath(id, kind, extension) { const value = String(id || ''); const resource = value.includes(':') ? value : `minecraft:${value}`; if (!/^[a-z0-9_.-]+:[a-z0-9_./-]+$/.test(resource) || resource.split(':')[1].split('/').some((part) => !part || part === '.' || part === '..')) { throw new Error(`Invalid item resource ID: ${value}`); } const [namespace, name] = resource.split(':'); return `assets/${namespace}/${kind}/${name}${extension}`; } function indexPropZip(bytes) { const data = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes), files = new Map(); if (data.length > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.'); let end = -1; for (let offset = data.length - 22; offset >= Math.max(0, data.length - 65557); offset--) { if (data.readUInt32LE(offset) === 0x06054b50 && offset + 22 + data.readUInt16LE(offset + 20) === data.length) { end = offset; break; } } if (end < 0) throw new Error('Not a supported ZIP/JAR archive.'); if (data.readUInt16LE(end + 4) || data.readUInt16LE(end + 6)) throw new Error('Split archives are unsupported.'); const count = data.readUInt16LE(end + 10), start = data.readUInt32LE(end + 16); const limit = start + data.readUInt32LE(end + 12); if (count === 65535 || start === 0xffffffff || limit > end) throw new Error('ZIP64 or invalid archive directory.'); let offset = start; for (let index = 0; index < count; index++) { if (offset + 46 > limit || data.readUInt32LE(offset) !== 0x02014b50) throw new Error('Invalid archive directory.'); const nameLength = data.readUInt16LE(offset + 28), extra = data.readUInt16LE(offset + 30), comment = data.readUInt16LE(offset + 32); if (offset + 46 + nameLength + extra + comment > limit) throw new Error('Truncated archive directory.'); const name = data.toString('utf8', offset + 46, offset + 46 + nameLength); if (name.startsWith('assets/') && !name.endsWith('/') && !name.includes('\\') && !name.split('/').includes('..')) { files.set(name, { flags: data.readUInt16LE(offset + 8), method: data.readUInt16LE(offset + 10), size: data.readUInt32LE(offset + 24), compressed: data.readUInt32LE(offset + 20), local: data.readUInt32LE(offset + 42) }); } offset += 46 + nameLength + extra + comment; } return { files, read(name) { const file = files.get(name); if (!file) return null; if (file.flags & 1 || file.size > 16 * 1024 * 1024) throw new Error('Encrypted or oversized asset entry.'); const local = file.local; if (local + 30 > data.length || data.readUInt32LE(local) !== 0x04034b50) throw new Error('Invalid asset entry.'); const start = local + 30 + data.readUInt16LE(local + 26) + data.readUInt16LE(local + 28); if (start + file.compressed > data.length) throw new Error('Truncated asset entry.'); const content = data.subarray(start, start + file.compressed); let decoded; if (file.method === 0) decoded = content; else if (file.method === 8) { const load = typeof requireNativeModule === 'function' ? requireNativeModule : require; const zlib = load('zlib'); if (!zlib?.inflateRawSync) throw new Error('ZIP decompression is unavailable. Allow zlib access or use extracted assets.'); decoded = zlib.inflateRawSync(content, { maxOutputLength: 16 * 1024 * 1024 }); } else throw new Error('Unsupported asset compression method.'); if (decoded.length !== file.size) throw new Error('Asset entry size mismatch.'); return decoded; } }; } function openPropSource(file, io = desktopFiles()) { const resolved = importFilePath(file, io), stats = io.fs.statSync(resolved); const signature = `${resolved}:${stats.mtimeMs}:${stats.size}`; if (propSources.has(signature)) return propSources.get(signature); let source; if (stats.isDirectory()) { const root = io.path.basename(resolved) === 'assets' ? io.path.dirname(resolved) : resolved; source = { read(name) { const target = io.path.join(root, ...name.split('/')); if (!io.fs.existsSync(target)) return null; if (!insidePack(root, importFilePath(target, io), io)) throw new Error('Asset path escapes the selected folder.'); if (io.fs.statSync(target).size > 16 * 1024 * 1024) throw new Error('Asset exceeds the 16 MiB limit.'); return Buffer.from(io.fs.readFileSync(target)); }, itemIds() { const ids = [], assets = io.path.join(root,'assets'); if (!io.fs.existsSync(assets)) return ids; for (const namespace of io.fs.readdirSync(assets).slice(0,256)) { const folder = io.path.join(assets,namespace,'items'); if (!io.fs.existsSync(folder)) continue; const scan = (folder,prefix = '',depth = 0) => { if (depth > 8 || ids.length >= 10000) return; insidePack(root,folder,io); for (const entry of io.fs.readdirSync(folder,{withFileTypes:true})) { if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) scan(io.path.join(folder,entry.name),`${prefix}${entry.name}/`,depth+1); else if (entry.name.endsWith('.json')) ids.push(`${namespace}:${prefix}${entry.name.slice(0,-5)}`); if (ids.length >= 10000) break; } }; scan(folder); } return ids.sort(); } }; } else { if (stats.size > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.'); source = indexPropZip(io.fs.readFileSync(resolved)); } source.path = resolved; // Keep only the most recently used archive rather than retaining several entire client JARs. propSources.clear(); propSources.set(signature, source); return source; } function discoverPropSources(io = desktopFiles()) { const results = []; const add = (file, label) => { if (io.fs.existsSync(file) && !results.some((entry) => entry.file === file)) results.push({ file, label }); }; if (typeof SystemInfo !== 'undefined' && SystemInfo.user_data_directory) { const cache = io.path.join(SystemInfo.user_data_directory, 'minecraft_assets_cache'); if (io.fs.existsSync(cache)) for (const name of io.fs.readdirSync(cache).slice(0, 500)) { if (/\.jar$/i.test(name)) add(io.path.join(cache, name), `Asset Browser: ${name.replace(/\.jar$/i, '')}`); } } let minecraft = typeof settings !== 'undefined' ? settings.ewan_minecraft_directory?.value : null; if (!minecraft && typeof SystemInfo !== 'undefined') { if (SystemInfo.platform === 'win32' && SystemInfo.appdata_directory) minecraft = io.path.join(SystemInfo.appdata_directory, '.minecraft'); else if (SystemInfo.home_directory) minecraft = io.path.join(SystemInfo.home_directory, ...(SystemInfo.platform === 'darwin' ? ['Library', 'Application Support', 'minecraft'] : ['.minecraft'])); } if (minecraft) { const versions = io.path.join(minecraft, 'versions'); if (io.fs.existsSync(versions)) for (const name of io.fs.readdirSync(versions).slice(0, 500)) { add(io.path.join(versions, name, `${name}.jar`), `Minecraft: ${name}`); } } const last = typeof localStorage !== 'undefined' ? localStorage.getItem('animationdirector_prop_source') : null; if (last) add(last, `Selected: ${io.path.basename(last)}`); return results; } function propJson(source, path) { const content = source.read(path); return content ? JSON.parse(content.toString('utf8').replace(/^\uFEFF/, '')) : null; } function resolvePropModel(source, id, ancestors = new Set()) { const path = propResourcePath(id, 'models', '.json'); if (ancestors.has(path) || ancestors.size >= 32) throw new Error(`Cyclic or excessive item model inheritance: ${id}`); const json = propJson(source, path); if (!json) throw new Error(`Missing item model: ${id}`); ancestors = new Set(ancestors); ancestors.add(path); let parent = {}; if (json.parent && !/^(minecraft:)?builtin\//.test(json.parent)) parent = resolvePropModel(source, json.parent, ancestors); const result = { ...parent, ...json, textures: { ...parent.textures, ...json.textures }, display: { ...parent.display, ...json.display } }; result.generated = json.parent && /^(minecraft:)?builtin\/generated$/.test(json.parent) || parent.generated || false; return result; } function propItemModels(node, warnings, depth = 0) { if (!node || depth > 20) throw new Error('Missing or excessive item definition tree.'); const type = String(node.type || '').replace(/^minecraft:/, ''); if (type === 'model' && typeof node.model === 'string') { if (node.tints?.length) warnings.push('This item uses component-dependent tinting; the preview uses its default tint.'); return [{ id: node.model, tints: node.tints || [] }]; } if (type === 'composite') return (node.models || []).flatMap((child) => propItemModels(child, warnings, depth + 1)); if (['condition', 'select', 'range_dispatch'].includes(type)) { warnings.push('Component-dependent item appearance: previewing its fallback/default branch.'); const child = type === 'condition' ? node.on_false : node.fallback; if (!child) throw new Error('This conditional item has no default appearance. Use a manual texture preview.'); return propItemModels(child, warnings, depth + 1); } throw new Error(`Item renderer '${type || 'unknown'}' is not supported. Use a manual texture preview.`); } function propTextureId(value, textures) { const seen = new Set(); while (typeof value === 'string' && value.startsWith('#')) { if (seen.has(value)) throw new Error(`Cyclic item texture reference: ${value}`); seen.add(value); value = textures[value.slice(1)]; } if (typeof value !== 'string') throw new Error('Missing item texture reference.'); return value; } function propDisplayTransform(model, slot) { const context = slot === 'floor' ? 'ground' : `thirdperson_${slot}hand`; const explicit = model.display?.[context]; const data = explicit || (slot === 'left' ? model.display?.thirdperson_righthand : null) || {}; const rotation = v3(data.rotation), translation = v3(data.translation), scale = v3(data.scale, 1); // Minecraft mirrors the selected display transform for left-hand rendering. if (slot === 'left') { translation[0] = -translation[0] || 0; rotation[1] = -rotation[1] || 0; rotation[2] = -rotation[2] || 0; } return { rotation, translation, scale }; } function manualPropModel(preset) { const generated = { generated: true, display: { ground: { translation: [0, 2, 0], scale: [0.5, 0.5, 0.5] }, thirdperson_righthand: { translation: [0, 3, 1], scale: [0.55, 0.55, 0.55] }, } }; if (preset === 'handheld') generated.display = { ...generated.display, thirdperson_righthand: { rotation: [0, -90, 55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] }, thirdperson_lefthand: { rotation: [0, 90, -55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] } }; return generated; } function loadPropAsset(source, item, slot) { propResourcePath(item, 'items', '.json'); const warnings = [], definition = propJson(source, propResourcePath(item, 'items', '.json')); const refs = definition ? propItemModels(definition.model, warnings) : [{ id: `${item.includes(':') ? item.split(':')[0] : 'minecraft'}:item/${item.split(':').pop()}`, tints: [] }]; const parts = []; for (const ref of refs) { const model = resolvePropModel(source, ref.id), transform = propDisplayTransform(model, slot); const textures = {}, frames = {}; const addTexture = (value) => { const id = propTextureId(value, model.textures); const png = source.read(propResourcePath(id, 'textures', '.png')); if (!png || png.length > 4 * 1024 * 1024) throw new Error(`Missing or oversized item texture: ${id}`); textures[id] ||= `data:image/png;base64,${png.toString('base64')}`; const metadata = propJson(source, propResourcePath(id, 'textures', '.png.mcmeta')); if (metadata?.animation) { warnings.push('Animated texture: the preview uses its first frame.'); frames[id] = metadata.animation; } return id; }; if (model.generated) { for (let layer = 0; layer < 5; layer++) { if (!model.textures[`layer${layer}`]) continue; const id = addTexture(model.textures[`layer${layer}`]); const tint = ref.tints[layer]; const color = tint?.value ?? tint?.default; parts.push({ generated: true, texture: id, png: textures[id], frame: frames[id], transform, tint: typeof color === 'number' ? color & 0xffffff : 0xffffff }); } } else if (Array.isArray(model.elements)) { if (model.elements.length > 2048) throw new Error('Item model exceeds the preview element limit.'); const elements = clone(model.elements); for (const element of elements) for (const face of Object.values(element.faces || {})) face.texture = addTexture(face.texture); parts.push({ elements, textures, frames, transform, tints: ref.tints.map((tint) => typeof (tint.value ?? tint.default) === 'number' ? (tint.value ?? tint.default) & 0xffffff : 0xffffff) }); } else throw new Error(`Model '${ref.id}' requires a special renderer. Use a manual texture preview.`); } if (!parts.length) throw new Error('The item model has no previewable geometry.'); return { parts, warnings: [...new Set(warnings)] }; } function readPropPreviews() { try { return JSON.parse(Project?.[PROP_PREVIEWS_PROPERTY] || '{}'); } catch (_) { return {}; } } function propEntryKey(animation, actor, slot, stage) { return JSON.stringify([animation, actor, slot, Number(stage)]); } function effectivePropEntry(entries, animation, actor, slot, stage) { return entries[propEntryKey(animation, actor, slot, stage)] ?? entries[propEntryKey(animation, actor, slot, 0)]; } function effectivePropItem(definition, actor, slot, stageNumber) { const field = `prop_${slot}`, stage = definition.stages?.find((stage) => stage.stage === stageNumber); if (stage?.props === null || stage?.props?.[actor] === null) return null; const props = stage?.props?.[actor]; if (props && Object.hasOwn(props, field)) return props[field]; return definition.actors?.find((entry) => entry.label === actor)?.[field]; } function importPropPreviews(animation, definition, preferredSources = [], existing = {}) { const entries = Object.fromEntries(Object.entries(existing).filter(([, entry]) => entry?.animation !== animation)); const warnings = [], requested = []; for (const actor of definition.actors || []) for (const slot of Object.keys(PROP_SLOTS)) { const base = effectivePropItem(definition, actor.label, slot, 0); if (typeof base === 'string') requested.push({ actor: actor.label, slot, stage: 0, item: base }); for (const stage of definition.stages || []) { const item = effectivePropItem(definition, actor.label, slot, stage.stage); if (item === base) continue; if (typeof item === 'string' || item === null && typeof base === 'string') { requested.push({ actor: actor.label, slot, stage: stage.stage, item }); } } } if (!requested.length) return { entries, warnings }; let io; try { io = desktopFiles(); } catch (_) { warnings.push('Preview assets require desktop Blockbench. Prop IDs remain in the imported definition.'); return { entries, warnings }; } const paths = new Set(); const addPath = (path) => { if (!path) return; try { const resolved = importFilePath(path, io); if (io.fs.statSync(resolved).isDirectory() || /\.(?:jar|zip)$/i.test(resolved)) paths.add(resolved); else paths.add(findImportPack(resolved, io).root); } catch (_) { /* A standalone definition may have no parent pack. */ } }; preferredSources.forEach(addPath); try { savedPackFolders().forEach(addPath); } catch (_) { /* No registered packs. */ } try { addPath(localStorage.getItem('animationdirector_prop_source')); } catch (_) { /* No saved source. */ } try { discoverPropSources(io).forEach((source) => addPath(source.file)); } catch (_) { /* No client assets found. */ } const sources = []; for (const path of paths) { try { sources.push({ path, data: openPropSource(path, io) }); } catch (_) { /* Try the next available source. */ } } const resolved = new Map(), missing = new Set(); for (const prop of requested) { const key = propEntryKey(animation, prop.actor, prop.slot, prop.stage); if (prop.item === null) { entries[key] = { animation, actor: prop.actor, slot: prop.slot, stage: prop.stage, mode: 'hidden' }; continue; } const assetKey = `${prop.slot}:${prop.item}`; if (!resolved.has(assetKey)) { let found = null; for (const source of sources) { try { found = { source: source.path, asset: loadPropAsset(source.data, prop.item, prop.slot) }; break; } catch (_) { /* Item resources may live in another source. */ } } resolved.set(assetKey, found); } const found = resolved.get(assetKey); if (!found) { missing.add(prop.item); continue; } entries[key] = { animation, actor: prop.actor, slot: prop.slot, stage: prop.stage, mode: 'item', item: prop.item, source: found.source, texture: '', preset: 'generated', asset: found.asset }; if (JSON.stringify(entries).length > 24 * 1024 * 1024) { delete entries[key]; missing.add(prop.item); } } if (missing.size) warnings.push(`No preview for ${[...missing].join(', ')}; choose an asset source in Item Props. The animdef items were preserved.`); return { entries, warnings }; } function writePropDefinition(definition, actor, slot, stage, mode, item) { const result = clone(definition), field = `prop_${slot}`; if (!['item','hidden','inherit'].includes(mode) || !Number.isInteger(Number(stage)) || Number(stage) < 0) throw new Error('Choose a valid prop behavior and stage.'); if (!PROP_SLOTS[slot] || !result.actors?.some((entry) => entry.label === actor)) throw new Error('Choose a valid actor and prop slot.'); if (mode === 'item') propResourcePath(item, 'items', '.json'); if (!stage) { const entry = result.actors.find((entry) => entry.label === actor); if (mode === 'item') entry[field] = item; else delete entry[field]; return result; } const entry = result.stages.find((entry) => entry.stage === Number(stage)); if (!entry) throw new Error(`Missing definition stage p${stage}.`); // Expanding an explicit all-hidden override must not accidentally unhide other slots/actors. if (entry.props === null) entry.props = Object.fromEntries(result.actors.map((entry) => [entry.label, null])); entry.props ||= {}; if (entry.props[actor] === null) entry.props[actor] = { prop_left: null, prop_right: null, prop_floor: null }; entry.props[actor] ||= {}; if (mode === 'inherit') delete entry.props[actor][field]; else entry.props[actor][field] = mode === 'hidden' ? null : item; if (!Object.keys(entry.props[actor]).length) delete entry.props[actor]; if (!Object.keys(entry.props).length) delete entry.props; return result; } function propAuthoringDefinition(id) { const group = collectConjoinedAnimationGroups().groups.find((group) => group.id === id); if (!group) throw new Error('Select an animation with actor tracks first.'); const analysis = analyzeAnimationDefinitionGroup(group), saved = getSavedAnimdef(id); const actors = analysis.actorIndices.map((index) => { const label = analysis.actorLabels.get(index), old = saved.actors?.find((actor) => actor.label === label); return { ...buildActorConstraint(label, getProjectActorAnimdefMetadata()[index] || {}), ...old, label }; }); const stages = saved.stages?.length ? saved.stages : analysis.stages.map((stage) => ({ stage: stage.stageNumber, loop: stage.loop, cycle_seconds: stage.cycleSeconds })); return { ...saved, actors, stages }; } async function decodePropPng(url, frame) { if (typeof Image === 'undefined' || typeof document === 'undefined') throw new Error('Image previews require Blockbench.'); const image = await new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Could not decode the preview PNG.')); image.src = url; }); if (image.width > 1024 || image.height > 8192 || !image.width || !image.height) throw new Error('Preview PNG dimensions are unsupported.'); const width = frame?.width || image.width, height = frame ? frame.height || width : image.height; const first = frame?.frames?.[0], index = typeof first === 'number' ? first : first?.index || 0; const columns = Math.floor(image.width / width), x = (index % columns) * width, y = Math.floor(index / columns) * height; if (!Number.isInteger(width) || !Number.isInteger(height) || width * height > 65536 || !Number.isInteger(index) || index < 0 || width <= 0 || height <= 0 || x + width > image.width || y + height > image.height) { throw new Error('Invalid or oversized animated texture frame.'); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context = canvas.getContext('2d'); context.drawImage(image,x,y,width,height,0,0,width,height); return { width, height, pixels: context.getImageData(0,0,width,height).data, canvas }; } function extrudePropPixels(width, height, pixels) { if (width * height > 65536) throw new Error('Extruded previews are limited to 65,536 pixels.'); const elements = [], solid = (x, y) => x >= 0 && y >= 0 && x < width && y < height && pixels[(y * width + x) * 4 + 3] > 0; // Front/back use one alpha-cutout plane. Only silhouette edges need thickness geometry. const faces = { north: { texture: 'sprite', uv: [16, 0, 0, 16] }, south: { texture: 'sprite', uv: [0, 0, 16, 16] } }; elements.push({ from: [0, 0, 7.5], to: [16, 16, 8.5], faces }); for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) if (solid(x, y)) { const edges = {}; const uv = [(x + 0.5) * 16 / width, (y + 0.5) * 16 / height]; const face = () => ({ texture: 'sprite', uv: [...uv, ...uv] }); if (!solid(x - 1, y)) edges.west = face(); if (!solid(x + 1, y)) edges.east = face(); if (!solid(x, y - 1)) edges.up = face(); if (!solid(x, y + 1)) edges.down = face(); if (Object.keys(edges).length) elements.push({ from: [x * 16 / width, 16 - (y + 1) * 16 / height, 7.5], to: [(x + 1) * 16 / width, 16 - y * 16 / height, 8.5], faces: edges }); } return elements; } function propFaceVertices(from, to, face) { const [x, y, z] = from, [X, Y, Z] = to; return { north: [[X,y,z],[x,y,z],[x,Y,z],[X,Y,z]], south: [[x,y,Z],[X,y,Z],[X,Y,Z],[x,Y,Z]], west: [[x,y,z],[x,y,Z],[x,Y,Z],[x,Y,z]], east: [[X,y,Z],[X,y,z],[X,Y,z],[X,Y,Z]], up: [[x,Y,Z],[X,Y,Z],[X,Y,z],[x,Y,z]], down: [[x,y,z],[X,y,z],[X,y,Z],[x,y,Z]] }[face]; } function propDefaultUv(from, to, face) { const [x,y,z] = from, [X,Y,Z] = to; return { north: [16-X,16-Y,16-x,16-y], south: [x,16-Y,X,16-y], west: [z,16-Y,Z,16-y], east: [16-Z,16-Y,16-z,16-y], up: [x,z,X,Z], down: [x,16-Z,X,16-z] }[face]; } async function buildPropMesh(asset) { const root = new THREE.Group(), images = new Map(); try { for (const [partIndex, part] of asset.parts.entries()) { const pngs = part.generated ? { sprite: part.png } : part.textures; const partImages = new Map(); for (const [id, url] of Object.entries(pngs)) { const frame = part.generated ? part.frame : part.frames?.[id], key = url + JSON.stringify(frame || null); if (!images.has(key)) images.set(key, await decodePropPng(url, frame)); partImages.set(id, images.get(key)); } const sprite = partImages.get('sprite'); const elements = part.generated ? extrudePropPixels(sprite.width, sprite.height, sprite.pixels) : part.elements; const transform = new THREE.Group(); root.add(transform); transform.position.fromArray(part.transform.translation); transform.rotation.set(...part.transform.rotation.map((value) => value * Math.PI / 180), 'XYZ'); transform.scale.fromArray(part.transform.scale); const centered = new THREE.Group(); centered.position.set(-8, -8, -8); transform.add(centered); const buffers = new Map(); for (const element of elements) { const from = v3(element.from), to = v3(element.to); const rotation = element.rotation; const matrix = new THREE.Matrix4(); if (rotation) { if (!['x', 'y', 'z'].includes(rotation.axis)) throw new Error('Unsupported item element rotation axis.'); const pivot = v3(rotation.origin, 8), angle = num(rotation.angle) * Math.PI / 180; const quaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(...['x','y','z'].map((axis) => axis === rotation.axis ? 1 : 0)), angle); const scale = new THREE.Vector3(1,1,1); if (rotation.rescale) for (const axis of ['x','y','z']) if (axis !== rotation.axis) scale[axis] = 1 / Math.cos(angle); matrix.compose(new THREE.Vector3(...pivot), quaternion, scale).multiply(new THREE.Matrix4().makeTranslation(...pivot.map((value) => -value))); } for (const [side, face] of Object.entries(element.faces || {})) { const vertices = propFaceVertices(from, to, side); if (!vertices || !pngs[face.texture]) continue; const tint = part.tint ?? part.tints?.[face.tintindex] ?? 0xffffff, key = `${face.texture}:${tint}`; if (!buffers.has(key)) buffers.set(key, { texture: face.texture, tint, positions: [], uvs: [] }); const buffer = buffers.get(key), uv = face.uv || propDefaultUv(from,to,side); const coords = [[uv[0],uv[3]],[uv[2],uv[3]],[uv[2],uv[1]],[uv[0],uv[1]]]; const turns = ((face.rotation || 0) / 90) % 4; for (let count = 0; count < turns; count++) coords.unshift(coords.pop()); for (const index of [0,1,2,0,2,3]) { const vector = new THREE.Vector3(...vertices[index]).applyMatrix4(matrix); buffer.positions.push(vector.x,vector.y,vector.z); buffer.uvs.push(coords[index][0]/16, 1-coords[index][1]/16); } } } for (const buffer of buffers.values()) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(buffer.positions,3)); geometry.setAttribute('uv', new THREE.Float32BufferAttribute(buffer.uvs,2)); geometry.computeVertexNormals(); const texture = new THREE.CanvasTexture(partImages.get(buffer.texture).canvas); texture.magFilter = THREE.NearestFilter; texture.minFilter = THREE.NearestFilter; if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace; else if (THREE.sRGBEncoding) texture.encoding = THREE.sRGBEncoding; const material = new THREE.MeshLambertMaterial({ map: texture, side: THREE.DoubleSide, alphaTest: 0.01, transparent: false, color: buffer.tint, polygonOffset: !!part.generated, polygonOffsetFactor: -partIndex, polygonOffsetUnits: -partIndex }); centered.add(new THREE.Mesh(geometry, material)); } } return root; } catch (error) { disposePropMesh(root); throw error; } } function disposePropMesh(root) { root.parent?.remove(root); root.traverse((node) => { node.geometry?.dispose(); if (node.material) { node.material.map?.dispose(); node.material.dispose(); } }); } function clearPropMeshes() { propPreviewGeneration++; for (const mesh of propMeshes.values()) disposePropMesh(mesh); propMeshes.clear(); propPreviewSignature = null; } async function refreshPropPreviews(force = false) { const selected = typeof Animation !== 'undefined' ? parseAfwStageAnimationName(getSelectedAnimation()) : null; const raw = Project?.[PROP_PREVIEWS_PROPERTY] || '{}'; if (raw === '{}' && !propMeshes.size) return; const bones = typeof Group !== 'undefined' ? Group.all.filter((group) => /_prop(left|right|floor)$/.test(group.name)).map((group) => `${group.uuid}:${group.name}`).join(',') : ''; const definitions = Project?.[ANIMDEF_DEFINITIONS_PROPERTY]; const signature = { project: Project?.uuid, animation: selected?.name, raw, bones, definitions }; if (!force && signature.project === propPreviewSignature?.project && signature.animation === propPreviewSignature?.animation && raw === propPreviewSignature?.raw && bones === propPreviewSignature?.bones && definitions === propPreviewSignature?.definitions) { for (const [uuid, mesh] of propMeshes) { const bone = Group.all.find((group) => group.uuid === uuid); if (bone?.mesh && mesh.parent !== bone.mesh) bone.mesh.add(mesh); } return; } clearPropMeshes(); propPreviewSignature = signature; if (!Project || !selected || typeof THREE === 'undefined') return; const generation = propPreviewGeneration, entries = readPropPreviews(), definition = getSavedAnimdef(selected.id); for (const index of getSortedUsedActorIndicesFromProject()) for (const slot of Object.keys(PROP_SLOTS)) { const actor = getActorExportLabel(index), entry = effectivePropEntry(entries, selected.id, actor, slot, selected.stageNumber); if (!entry?.asset || entry.mode !== 'item') continue; const item = effectivePropItem(definition, actor, slot, selected.stageNumber); if (item === null) continue; const bone = Group.all.find((group) => group.name === `actor${index}_${PROP_SLOTS[slot]}`); if (!bone?.mesh) continue; try { const asset = item && item !== entry.item ? (!entry.texture && entry.source ? loadPropAsset(openPropSource(entry.source),item,slot) : null) : entry.asset; if (!asset) continue; const mesh = await buildPropMesh(asset); if (generation !== propPreviewGeneration) { disposePropMesh(mesh); return; } bone.mesh.add(mesh); propMeshes.set(bone.uuid, mesh); } catch (error) { console.warn('Could not build item prop preview:', error); } } } async function applyPropPreview(values) { const projectId = Project?.uuid; if (!projectId) throw new Error('Open an animation project first.'); const definition = propAuthoringDefinition(values.animation), stage = Number(values.stage), slot = values.slot, actor = values.actor; const item = String(values.item || '').trim(); const updated = writePropDefinition(definition, actor, slot, stage, values.mode, item); let asset = null; if (values.mode === 'item') { if (values.texture) { const io = desktopFiles(), png = io.fs.readFileSync(values.texture); if (png.length > 4 * 1024 * 1024) throw new Error('Manual texture exceeds the 4 MiB limit.'); asset = { parts: [{ generated: true, png: `data:image/png;base64,${Buffer.from(png).toString('base64')}`, transform: propDisplayTransform(manualPropModel(values.preset),slot) }], warnings: [] }; } else { if (!values.source) throw new Error('Select a Minecraft JAR/assets folder or a manual PNG texture.'); asset = loadPropAsset(openPropSource(values.source), item, slot); } // Validate image/geometry before changing project metadata or opening an undo edit. const validation = await buildPropMesh(asset); disposePropMesh(validation); } if (Project?.uuid !== projectId) throw new Error('The active project changed. Try again.'); const key = propEntryKey(values.animation,actor,slot,stage), entries = readPropPreviews(); if (values.mode === 'inherit') delete entries[key]; else entries[key] = { animation: values.animation, actor, slot, stage, mode: values.mode, item, source: values.source, texture: values.texture, preset: values.preset, asset }; const raw = JSON.stringify(entries); if (raw.length > 24 * 1024 * 1024) throw new Error('Project prop previews exceed the 24 MiB embedded asset limit.'); Undo.initEdit({ prop_preview_edit: true, animdef_edit: true }); Project[PROP_PREVIEWS_PROPERTY] = raw; saveAnimdef(values.animation, updated); Undo.finishEdit('Edit item prop', { prop_preview_edit: true, animdef_edit: true }); Project.saved = false; await refreshPropPreviews(true); return asset?.warnings || []; } function createPropDialog() { const animationIds = collectConjoinedAnimationGroups().groups.map((group) => group.id); const selected = parseAfwStageAnimationName(getSelectedAnimation()); const state = { projectId: Project.uuid, animation: selected && animationIds.includes(selected.id) ? selected.id : animationIds[0] || '', actor: '', slot: 'right', stage: '0', source: '', jar: '', folder: '', choice: '', actors: [], stages: [], sources: discoverPropSources(), items: [], busy: false, }; const addSource = (file) => { if (file && !state.sources.some((entry) => entry.file === file)) state.sources.push({ file, label: `Selected: ${file}` }); }; const indexItems = () => { state.items = []; if (!state.source) return; const source = openPropSource(state.source); if (source.files) { const modern = [...source.files.keys()].filter((name) => /^assets\/[^/]+\/items\/.+\.json$/.test(name)); state.items = modern.length ? modern.map((name) => name.replace(/^assets\/([^/]+)\/items\/(.+)\.json$/, '$1:$2')).sort() : [...source.files.keys()].filter((name) => /^assets\/[^/]+\/models\/item\/.+\.json$/.test(name)) .map((name) => name.replace(/^assets\/([^/]+)\/models\/item\/(.+)\.json$/, '$1:$2')).sort(); } else state.items = source.itemIds(); }; const selectAnimation = () => { const definition = state.animation ? propAuthoringDefinition(state.animation) : { actors: [], stages: [] }; state.actors = definition.actors.map((actor) => actor.label); state.stages = definition.stages.map((stage) => stage.stage); if (!state.actors.includes(state.actor)) state.actor = state.actors[0] || ''; if (!state.stages.includes(Number(state.stage))) state.stage = '0'; }; const savedEntry = () => { const entry = readPropPreviews()[propEntryKey(state.animation, state.actor, state.slot, state.stage)]; const definition = getSavedAnimdef(state.animation), field = `prop_${state.slot}`; const fallback = definition.actors?.find((actor) => actor.label === state.actor)?.[field]; const stage = definition.stages?.find((stage) => stage.stage === Number(state.stage)); const override = stage?.props?.[state.actor]; const value = Number(state.stage) ? (stage?.props === null || override === null ? null : override?.[field]) : fallback; if (entry?.source) { addSource(entry.source); state.source = entry.source; } return { mode: entry?.mode || (value === null ? 'hidden' : value !== undefined ? 'item' : Number(state.stage) ? 'inherit' : 'item'), item: entry?.item || value || fallback || 'minecraft:carrot', texture: entry?.texture || '', preset: entry?.preset || 'generated', }; }; selectAnimation(); state.source = localStorage.getItem('animationdirector_prop_source') || state.sources[0]?.file || ''; const initial = savedEntry(); addSource(state.source); try { indexItems(); } catch (error) { console.warn('Could not index item assets:', error); state.items = []; } const form = { _target: { type: 'info', text: '### Prop target\nChoose the actor bone and the stages that should use this item.' }, animation: { label: 'Animation', type: 'select', value: state.animation, options: Object.fromEntries(animationIds.map((id) => [id, id])) }, actor: { label: 'Actor', type: 'select', value: state.actor, options: () => Object.fromEntries(state.actors.map((id) => [id, id])) }, slot: { label: 'Prop slot', type: 'select', value: state.slot, options: { right: 'Right hand', left: 'Left hand', floor: 'Floor' } }, stage: { label: 'Scope', type: 'select', value: state.stage, options: () => ({ 0: 'Default (all stages)', ...Object.fromEntries(state.stages.map((id) => [String(id), `Stage p${id}`])) }) }, mode: { label: 'Behavior', type: 'select', value: initial.mode, options: () => ({ item: 'Use item', ...state.stage !== '0' ? { hidden: 'Hide prop' } : {}, inherit: state.stage === '0' ? 'Remove default prop' : 'Inherit default prop' }) }, _assets: { type: 'info', text: '### Minecraft assets\nUse local Minecraft assets or a manual PNG for the editor preview. The preview is not exported.' , condition: (result) => result.mode === 'item' }, source: { label: 'Version / asset source', type: 'select', value: state.source, options: () => ({ '': 'Select assets...', ...Object.fromEntries(state.sources.map((entry) => [entry.file, entry.label])) }), condition: (result) => result.mode === 'item' }, jar: { label: 'Add client JAR', type: 'file', extensions: ['jar', 'zip'], readtype: 'none', value: '', condition: (result) => result.mode === 'item' }, folder: { label: 'Add extracted assets folder', type: 'folder', value: '', condition: (result) => result.mode === 'item' }, item: { label: 'Item ID', type: 'text', value: initial.item, placeholder: 'minecraft:carrot', description: 'The in-game item saved to the animdef. You can also type an ID not listed below.', condition: (result) => result.mode === 'item' }, item_choice: { label: 'Choose item', type: 'select', value: '', options: () => ({ '': 'Select an item...', ...Object.fromEntries(state.items.map((id) => [id, id])) }), condition: (result) => result.mode === 'item' && !!state.items.length }, _manual: { type: 'info', text: '### Manual texture fallback\nFor items with a special renderer, choose a PNG to preview an extruded sprite. This does not change the in-game model.', condition: (result) => result.mode === 'item' }, texture: { label: 'Preview texture (optional)', type: 'file', extensions: ['png'], readtype: 'none', value: initial.texture, condition: (result) => result.mode === 'item' }, preset: { label: 'Display preset', type: 'select', value: initial.preset, options: { generated: 'Generated item (food, materials)', handheld: 'Handheld item (tools, swords)' }, condition: (result) => result.mode === 'item' && !!result.texture }, }; const dialog = new Dialog({ id: 'animationdirector_props', title: 'Item Props', width: 760, form, buttons: ['Cancel', 'Apply prop'], cancelIndex: 0, confirmIndex: 1, onFormChange(result) { const nextSource = result.jar && result.jar !== state.jar ? result.jar : result.folder && result.folder !== state.folder ? result.folder : result.source; const sourceChanged = nextSource !== state.source; const targetChanged = result.animation !== state.animation || result.actor !== state.actor || result.slot !== state.slot || result.stage !== state.stage; state.jar = result.jar; state.folder = result.folder; if (sourceChanged) { state.source = nextSource; addSource(nextSource); state.choice = ''; this.form.setValues({ source: nextSource, item_choice: '' }, false); try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); } } if (result.animation !== state.animation) { state.animation = result.animation; state.actor = ''; state.stage = '0'; selectAnimation(); const previousSource = state.source, entry = savedEntry(); state.choice = ''; this.form.setValues({ actor: state.actor, stage: state.stage, source: state.source, item_choice: '', ...entry }, false); if (state.source !== previousSource) { try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); } state.choice = ''; this.form.setValues({ item_choice: '' }, false); } } else if (result.actor !== state.actor || result.slot !== state.slot || result.stage !== state.stage) { state.actor = result.actor; state.slot = result.slot; state.stage = result.stage; const previousSource = state.source, entry = savedEntry(); state.choice = ''; this.form.setValues({ source: state.source, item_choice: '', ...entry }, false); if (state.source !== previousSource) { try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); } state.choice = ''; this.form.setValues({ item_choice: '' }, false); } } if (!sourceChanged && !targetChanged && result.item_choice && result.item_choice !== state.choice) { state.choice = result.item_choice; this.form.setValues({ item: result.item_choice }, false); } this.form.update(this.form.getResult()); }, onConfirm(result) { if (state.busy) return false; state.busy = true; const values = { animation: result.animation, actor: result.actor, slot: result.slot, stage: result.stage, mode: result.mode, item: result.item, source: state.source, texture: result.texture, preset: result.preset }; void applyPropPreview(values).then((warnings) => { localStorage.setItem('animationdirector_prop_source', state.source); const index = getSortedUsedActorIndicesFromProject().find((index) => getActorExportLabel(index) === values.actor); const missingBone = !Group.all.some((group) => group.name === `actor${index}_${PROP_SLOTS[values.slot]}`); this.hide(); Blockbench.showQuickMessage([`Item prop saved.`, ...warnings, ...(missingBone ? [`Add the ${PROP_SLOTS[values.slot]} bone to see its preview.`] : [])].join(' '), 4500); }).catch((error) => { Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] }); }).finally(() => { state.busy = false; }); return false; }, }); propDialog = dialog; } function showPropDialog() { if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500); try { if (!collectConjoinedAnimationGroups().groups.length) { Blockbench.showQuickMessage('Create or import an animation before editing item props.', 3000); return; } propDialog?.delete(); createPropDialog(); propDialog.show(); } catch (error) { Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] }); } } function desktopFiles() { const load = typeof requireNativeModule === 'function' ? requireNativeModule : typeof require === 'function' ? require : null; if (!load) throw new Error('Automatic import requires desktop Blockbench. The Manual Import tools remain available in the browser.'); const fs = load('fs'), path = load('path'); if (!fs || !['readFileSync', 'statSync', 'existsSync', 'readdirSync'].every((method) => typeof fs[method] === 'function')) { throw new Error('Filesystem access is unavailable. Allow filesystem access for AnimationDirector and try again.'); } return { fs, path }; } function importFilePath(file, io) { if (typeof io.fs.realpathSync === 'function') return io.fs.realpathSync(file); const resolved = io.path.resolve(file); // Blockbench's scoped filesystem omits realpath/lstat. Dirent can still detect links. // Reject linked ancestors rather than silently weakening resource containment checks. io.checkedImportPaths ||= new Set(); const check = (candidate) => { if (io.checkedImportPaths.has(candidate)) return; const parent = io.path.dirname(candidate); if (parent !== candidate) { check(parent); io.importDirectoryEntries ||= new Map(); if (!io.importDirectoryEntries.has(parent)) { io.importDirectoryEntries.set(parent, io.fs.readdirSync(parent, { withFileTypes: true })); } const name = io.path.basename(candidate); const entry = io.importDirectoryEntries.get(parent).find((item) => io.path.sep === '\\' ? item.name.toLowerCase() === name.toLowerCase() : item.name === name); if (entry?.isSymbolicLink()) throw new Error(`Linked paths are not supported by this Blockbench filesystem. Select the actual extracted pack folder: ${candidate}`); } io.checkedImportPaths.add(candidate); }; check(resolved); return resolved; } function savedPackFolders() { try { const value = JSON.parse(localStorage.getItem(PACK_FOLDERS_KEY) || '[]'); return Array.isArray(value) ? uniqueTrimmedStrings(value.filter((item) => typeof item === 'string')) : []; } catch (_) { return []; } } function readImportJson(file, io) { if (io.fs.statSync(file).size > 128 * 1024 * 1024) throw new Error(`JSON file is too large: ${file}`); return JSON.parse(io.fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } function insidePack(root, relative, io) { const file = io.path.resolve(root, relative); const contained = (candidate) => { const rel = io.path.relative(root, candidate); return rel !== '..' && !rel.startsWith(`..${io.path.sep}`) && !io.path.isAbsolute(rel); }; if (!contained(file)) throw new Error(`Resource path escapes its pack: ${relative}`); if (!io.fs.existsSync(file)) return null; const real = importFilePath(file, io); if (!contained(real)) throw new Error(`Resource symlink escapes its pack: ${relative}`); return real; } function importResourceId(value) { if (typeof value !== 'string' || !RESOURCE_ID_PATTERN.test(value)) return null; const [namespace, resource] = value.split(':'); if (resource.split('/').some((part) => !part || part === '.' || part === '..')) return null; return { namespace, resource }; } function readImportPack(folder, io) { const root = importFilePath(folder, io); const metadata = readImportJson(insidePack(root, 'pack.mcmeta', io) || (() => { throw new Error(`Select an extracted pack folder containing pack.mcmeta: ${folder}`); })(), io); const afw = metadata.animationframework || {}; return { root, metadata: afw, id: importResourceId(afw.id) ? afw.id : null, name: typeof afw.name === 'string' ? afw.name : io.path.basename(root) }; } function findImportPack(file, io) { let folder = io.path.dirname(io.path.resolve(file)); while (true) { if (io.fs.existsSync(io.path.join(folder, 'pack.mcmeta'))) return readImportPack(folder, io); const parent = io.path.dirname(folder); if (parent === folder) throw new Error('The selected file must be inside an extracted pack containing pack.mcmeta. ZIP files are not supported.'); folder = parent; } } function importPackRegistry(source, folders, io) { const packs = [], warnings = [], seen = new Set(); // An unregistered source goes first; registered sources retain their configured priority. for (const folder of [ ...folders, source.root ]) { try { const pack = readImportPack(folder, io); if (!seen.has(pack.root)) { packs.push(pack); seen.add(pack.root); } } catch (error) { warnings.push(error.message); } } if (!folders.some((folder) => { try { return importFilePath(folder, io) === source.root; } catch (_) { return false; } })) { const index = packs.findIndex((pack) => pack.root === source.root); if (index >= 0) packs.unshift(...packs.splice(index, 1)); } const ids = new Set(); for (const pack of packs) { if (pack.id && ids.has(pack.id)) warnings.push(`Duplicate pack ID ${pack.id}; configured folder priority decides resource overrides.`); if (pack.id) ids.add(pack.id); } return { packs, warnings }; } function findImportResource(packs, id, directory, extension, io) { const parsed = importResourceId(id); if (!parsed) return null; const relative = `assets/${parsed.namespace}/${directory}${parsed.resource}${extension}`; for (const pack of packs) { const file = insidePack(pack.root, relative, io); if (file && io.fs.statSync(file).isFile()) return { file, pack, id }; } return null; } function importModelCandidates(entity, gender, variant, slim = false) { const parsed = importResourceId(entity); if (!parsed || !['m', 'f', 'mf'].includes(gender)) throw new Error('Choose a concrete entity ID and model gender for every actor.'); if (variant && (!/^[a-z0-9/._-]+$/.test(variant) || variant.split('/').some((part) => !part || part === '..' || part === '.'))) { throw new Error(`Invalid entity variant: ${variant}`); } const paths = variant ? [`${parsed.resource}_${variant}`, parsed.resource] : [parsed.resource]; if (slim && entity === 'minecraft:player') paths.unshift('player_slim'); const fallbacks = gender === 'mf' ? ['m', 'f'] : [gender === 'm' ? 'f' : 'm']; return [...paths.map((base) => `${base}.${gender}`), ...paths, ...fallbacks.flatMap((suffix) => paths.map((base) => `${base}.${suffix}`))]; } function resolveImportModel(source, packs, entity, gender, variant, io, slim = false) { const roots = []; const addRoot = (id, kind) => { if (roots.length < 32 && importResourceId(id) && !roots.some((root) => root.id === id)) roots.push({ id, kind }); }; if (source.id) addRoot(`${source.id}/entity`, 'Local'); for (const entry of Array.isArray(source.metadata.model_imports) ? source.metadata.model_imports : []) { if (entry?.pack !== source.id && Array.isArray(entry?.entities) && entry.entities.includes(entity)) { addRoot(`${entry.pack}/entity`, `Imported ${entry.pack}`); } } const revision = (value) => Number.isInteger(value) && value > 0 ? value : 1; const target = revision(source.metadata.default_model_revision); const current = Math.max(1, ...packs.filter((pack) => pack.id === 'needsofnature:default_pack') .map((pack) => revision(pack.metadata.default_model_revision))); // Sparse archives: only changed models exist in each old revision directory. for (let number = target; number < current && roots.length < 32; number++) { addRoot(`needsofnature:default_pack_compat/revision_${number}/entity`, `Default revision ${number}`); } roots.push({ id: 'animationframework:entity', kind: 'Shared' }); roots.push({ id: 'animationframework:', kind: 'Shared legacy' }); // Gender-specific candidates precede generic candidates across ALL providers, as in NoN. for (const candidate of importModelCandidates(entity, gender, variant, slim)) { for (const root of roots) { const id = root.id.endsWith(':') ? root.id + candidate : `${root.id}/${candidate}`; const found = findImportResource(packs, id, 'geckolib/models/', '.geo.json', io); if (found) return { ...found, kind: root.kind }; } } return null; } function definitionActorKeys(actors) { const sanitize = (value) => String(value).toLowerCase().replace(/[^a-z0-9_]/g, '_'); const counts = new Map(); return actors.map((actor) => { let base; if (actor.label) base = sanitize(actor.label); else { base = (actor.entity_types || []).map((id) => String(id).split(':').pop()).sort().join('_') || 'actor'; if (actor.actor_tags?.length) base += '_' + [...actor.actor_tags].sort().map(sanitize).join('_'); if (actor.actor_tags_any?.length) base += '_any_' + [...actor.actor_tags_any].sort().map(sanitize).join('_'); if (actor.entity_variant) base += '_' + actor.entity_variant; if (actor.age === 'baby') base += '_baby'; base = sanitize(base); } const count = (counts.get(base) || 0) + 1; counts.set(base, count); return count === 1 ? base : `${base}_${count}`; }); } function inferImportGender(actor) { const required = actor.actor_tags || []; const male = required.includes('gender.male'), female = required.includes('gender.female'); // Matching either flag also permits male+female actors: let creators select the intended model. if (male && female) return 'mf'; return ''; } function defaultImportClipRoles(actors, clipLabels) { const keys = definitionActorKeys(actors); const assigned = actors.map((_, index) => clipLabels.includes(keys[index]) ? keys[index] : ''); const entityNames = actors.map((actor) => actor.entity_types?.length === 1 ? String(actor.entity_types[0]).split(':').pop() : ''); actors.forEach((_, index) => { const name = entityNames[index]; // Older clips often omit constraint tags. Only infer a short label when unambiguous. if (!assigned[index] && name && clipLabels.includes(name) && !assigned.includes(name) && entityNames.filter((candidate, position) => candidate === name && !assigned[position]).length === 1) assigned[index] = name; }); return assigned; } function validateImportGeometry(json) { const geo = extractGeckoGeometryRoot(json); if (!geo || !Array.isArray(geo.bones) || !geo.bones.length) throw new Error('The model must contain a non-empty minecraft:geometry bones array.'); const bones = new Map(); for (const bone of geo.bones) { if (!bone || typeof bone.name !== 'string' || !bone.name || bones.has(bone.name)) throw new Error('Model bone names must be non-empty and unique.'); bones.set(bone.name, bone); } const completed = new Set(); for (const bone of bones.values()) { const visited = new Set(); let cursor = bone; while (cursor && !completed.has(cursor.name)) { if (visited.has(cursor.name)) throw new Error(`Cyclic model hierarchy at ${cursor.name}.`); visited.add(cursor.name); cursor = bones.get(cursor.parent); } for (const name of visited) completed.add(name); } return geo; } function companionImportFiles(source, file, io) { const relative = io.path.relative(source.root, io.path.resolve(file)).replace(/\\/g, '/'); const definition = /^data\/([^/]+)\/afw_animdefs\/(.+)\.json$/.exec(relative); const animation = /^assets\/([^/]+)\/(?:geckolib\/)?animations\/afw\/(.+)\.animation\.json$/.exec(relative); const match = definition || animation; if (!match || !importResourceId(`${match[1]}:${match[2]}`)) { throw new Error('Choose data//afw_animdefs/.json or assets//geckolib/animations/afw/.animation.json.'); } let id = match[2]; const parts = id.split('/'); if (animation && parts.length > 1 && parts.at(-1) === parts.at(-2)) id = parts.slice(0, -1).join('/'); const namespace = match[1]; const namespaces = (kind) => { const folder = insidePack(source.root, kind, io); return folder ? io.fs.readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : []; }; const candidates = (kind, middles, names, suffix) => [...new Set([namespace, ...namespaces(kind)])] .flatMap((ns) => middles.flatMap((middle) => names.map((name) => insidePack(source.root, `${kind}/${ns}/${middle}/${name}${suffix}`, io)))).filter(Boolean); return { id, definitions: definition ? [io.path.resolve(file)] : candidates('data', ['afw_animdefs'], [id], '.json'), animations: animation ? [io.path.resolve(file)] : candidates('assets', ['geckolib/animations/afw', 'animations/afw'], [id, `${id}/${id.split('/').pop()}`], '.animation.json') }; } function importDialog(title, form, confirmText = 'Continue', onFormChange = null) { return new Promise((resolve) => new Dialog({ id: 'animationdirector_automatic_import_step', title, width: 680, form, buttons: ['Cancel', confirmText], cancelIndex: 0, confirmIndex: 1, ...(onFormChange ? { onFormChange } : {}), onConfirm(result) { resolve(result); }, onCancel() { resolve(null); } }).show()); } function pickImportFile(title) { return new Promise((resolve) => Filesystem.importFile({ type: 'JSON', title, extensions: ['json'], multiple: false, readtype: 'text' }, (files) => resolve(files?.[0] || null))); } async function chooseCompanion(files, title) { if (files.length === 1) return files[0]; if (!files.length) return (await pickImportFile(`${title} (not found in pack)`))?.path || null; const result = await importDialog(title, { file: { type: 'select', label: 'File', value: files[0], options: { '': 'Choose a file...', ...Object.fromEntries(files.map((file) => [file, file])) } } }); if (!result) return null; if (!files.includes(result.file)) throw new Error('Select the companion file explicitly.'); return result.file; } function editImportPackFolders() { const io = desktopFiles(); const folders = savedPackFolders(); const dialog = new Dialog({ id: 'animationdirector_pack_folders', title: 'Animation Import Pack Folders', width: 680, component: { data() { return { folders, error: '' }; }, methods: { add() { const folder = Blockbench.pickDirectory({ title: 'Select extracted pack folder', resource_id: PACK_FOLDERS_KEY }); if (!folder) return; try { const pack = readImportPack(folder, io); if (!this.folders.includes(pack.root)) this.folders.push(pack.root); this.error = ''; } catch (error) { this.error = error.message; } }, move(index, direction) { const next = index + direction; if (next < 0 || next >= this.folders.length) return; const item = this.folders.splice(index, 1)[0]; this.folders.splice(next, 0, item); }, }, template: `

Highest priority first. Add the default pack and any model-import provider packs. These folders are remembered for future imports; Minecraft's load order is unchanged.

{{folder}}

{{error}}

`, }, onConfirm() { localStorage.setItem(PACK_FOLDERS_KEY, JSON.stringify(folders)); }, }); dialog.show(); } function loadImportTextures(row, packs, io) { const candidates = actorTextureCandidates(row.json, row.geo, row.resolved.file, row.entity, row.slim); row.textures = new Map(); row.defaultTexture = undefined; let cachedSources; let modelPack; try { modelPack = findImportPack(row.resolved.file, io); } catch (_) { /* Standalone model. */ } const searchPacks = modelPack && !packs.some((pack) => pack.root === modelPack.root) ? [modelPack, ...packs] : packs; const resolveTexture = (resource) => { const id = resource.includes(':') ? resource : `minecraft:${resource}`; const texture = findImportResource(searchPacks, id, '', '', io); if (texture) { let dimensions = null; try { if (io.fs.statSync(texture.file).size <= 4 * 1024 * 1024) { const bytes = io.fs.readFileSync(texture.file); let metadata = null; try { metadata = readImportJson(`${texture.file}.mcmeta`, io); } catch (_) { /* Static texture. */ } dimensions = importTextureDimensions(bytes, metadata); } } catch (_) { /* Let Blockbench report unreadable texture files. */ } return { ...texture, dimensions }; } if (!cachedSources) { cachedSources = []; for (const entry of discoverPropSources(io)) { try { cachedSources.push(openPropSource(entry.file, io)); } catch (_) { /* Try another asset source. */ } } } const parsed = importResourceId(id); if (!parsed) return null; const relative = `assets/${parsed.namespace}/${parsed.resource}`; for (const source of cachedSources) { let bytes; try { bytes = source.read(relative); } catch (_) { continue; } if (!bytes || bytes.length > 4 * 1024 * 1024) continue; let dimensions = pngDimensions(bytes); if (!dimensions) continue; try { dimensions = importTextureDimensions(bytes, propJson(source, `${relative}.mcmeta`)); } catch (_) { /* Keep the PNG dimensions when metadata is invalid. */ } return { bytes, relative, dimensions }; } return null; }; for (const resource of candidates) { const texture = resolveTexture(resource); if (!texture) continue; row.textures.set(resource, texture); row.defaultTexture = resource; break; } for (const resource of Object.values(row.json.afw_bone_textures || {})) { if (typeof resource !== 'string' || row.textures.has(resource)) continue; const texture = resolveTexture(resource); if (texture) row.textures.set(resource, texture); } } function commitAutomaticImport(plan) { if (!Formats.geckolib_model) throw new Error('Install/enable the GeckoLib Blockbench plugin before importing an animation.'); cancelBlockRequirementUpdate(); if (!newProject(Formats.geckolib_model)) throw new Error('Could not create the GeckoLib project.'); const projectId = Project.uuid; const aspects = { outliner: true, selection: true, elements: [], groups: [], textures: [], animations: [], uv_mode: true, automatic_animation_import: true }; Undo.initEdit(aspects); try { Project.name = plan.parsed.animationId; const description = plan.rows[0]?.geo.description || {}; const dimension = (value) => Number.isFinite(value) && value > 0 ? value : 64; Project.texture_width = dimension(description.texture_width); Project.texture_height = dimension(description.texture_height); const names = {}, textures = {}, metadata = {}, overrides = {}, loadedTextures = new Map(); for (const [offset, row] of plan.rows.entries()) { const index = offset + 1; names[index] = row.label; metadata[index] = plan.definition.actors[offset]; importGeometryIntoCurrentProject(row.geo, index, false, false); for (const [name, resource] of row.textures) { const key = resource.file || `${resource.relative}:${name}`; if (!loadedTextures.has(key)) { const texture = new Texture({ name, uv_width: resource.dimensions?.width || row.geo.description?.texture_width || 64, uv_height: resource.dimensions?.height || row.geo.description?.texture_height || 64 }); texture.keep_size = true; texture.load_callback = () => { if (Project?.uuid === projectId) scheduleActorTexturePreviewApply(); }; if (resource.file) texture.fromPath(resource.file); else texture.fromDataURL(`data:image/png;base64,${resource.bytes.toString('base64')}`); texture.name = name; if (resource.relative) texture.relative_path = resource.relative; texture.add(false); loadedTextures.set(key, name); } } const base = row.textures.get(row.defaultTexture); if (base) { textures[index] = loadedTextures.get(base.file || `${base.relative}:${row.defaultTexture}`); } for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) { const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`); const boneTexture = row.textures.get(textureName); if (group && boneTexture) overrides[group.uuid] = { uuid: group.uuid, name: group.name, texture: loadedTextures.get(boneTexture.file || `${boneTexture.relative}:${textureName}`) }; } } setProjectActorNames(names); setProjectActorTextures(textures); setProjectActorAnimdefMetadata(metadata); setProjectBoneTextureOverrides(overrides); setProjectAnimdefAnimators(plan.definition.animators || []); Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames); mapAnimationSounds(Animation.all); rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false); if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries); Canvas.updateAll(); applyAllActorTexturePreviews(false); refreshBlockGuideFaces(); const finished = { ...aspects, elements: Outliner.elements.slice(), groups: Group.all.slice(), textures: Texture.all.slice(), animations: Animation.all.slice() }; Undo.finishEdit('Automatically import animation', finished); Project.saved = false; if (Animation.all[0]) Animation.all[0].select(); if (plan.propEntries) void refreshPropPreviews(true); if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(plan.parsed.animationId); Blockbench.showQuickMessage(`Imported ${plan.parsed.animationId}: ${plan.rows.length} actors, ${plan.imported.stageNames.length} stages.`, 4000); } catch (error) { Undo.cancelEdit(); throw error; } } async function automaticallyImportAnimation() { try { const io = desktopFiles(); const file = await pickImportFile('Automatically Import Animation (definition or animation file)'); if (!file) return; const source = findImportPack(file.path, io); const companions = companionImportFiles(source, file.path, io); if (!/^[a-z0-9._-]+$/.test(companions.id)) { throw new Error('This editor requires an animation ID without subfolders. Nested /.animation.json files are supported.'); } const defFile = await chooseCompanion(companions.definitions, 'Choose Animation Definition'); if (!defFile) return; const animFile = await chooseCompanion(companions.animations, 'Choose Animation File'); if (!animFile) return; const definition = readImportJson(defFile, io); const parsed = parseConjoinedAnimationFile({ name: io.path.basename(animFile), path: animFile, content: JSON.stringify(readImportJson(animFile, io)) }); if (parsed.animationId !== companions.id.split('/').pop()) throw new Error('The companion animation filename must use the same animation ID.'); parsed.animationId = companions.id; if (!Array.isArray(definition.actors) || !definition.actors.length) { throw new Error('The definition must contain at least one actor.'); } const registry = importPackRegistry(source, savedPackFolders(), io); const keys = definitionActorKeys(definition.actors); const clipRoles = defaultImportClipRoles(definition.actors, parsed.actorLabels); const usedClips = new Set(clipRoles.filter(Boolean)); clipRoles.forEach((label, index) => { if (label) return; const available = parsed.actorLabels.find((candidate) => !usedClips.has(candidate)); if (available) { clipRoles[index] = available; usedClips.add(available); } }); const form = { info: { type: 'info', text: `### ${companions.id}\nImport into a NEW GeckoLib project. Use Manual Import > Pack Folders to register shared/default packs before importing.\nFolder priority: ${registry.packs.map((pack) => pack.name).join(' > ')}` }, actors_section: { type: 'info', text: '### Actors\nChoose concrete actors and confirm their animation roles.' }, }; definition.actors.forEach((actor, index) => { const entities = (actor.entity_types || []).filter((id) => importResourceId(id)); form[`heading_${index}`] = { type: 'info', text: `### Actor ${index + 1}: ${keys[index]}` }; form[`clip_${index}`] = { type: 'select', label: 'Animation actor', value: clipRoles[index], options: { '': 'Choose actor...', ...Object.fromEntries(parsed.actorLabels.map((label) => [label, label])) } }; form[`entity_${index}`] = entities.length ? { type: 'select', label: 'Entity', value: entities[0], options: Object.fromEntries(entities.map((id) => [id, id])) } : { type: 'text', label: 'Concrete entity ID', value: '', placeholder: 'minecraft:player' }; const required = actor.actor_tags || []; const genders = {}; if (!required.includes('gender.female')) genders.m = 'Male'; if (!required.includes('gender.male')) genders.f = 'Female'; genders.mf = 'Male + female'; form[`gender_${index}`] = { type: 'select', label: 'Model gender', value: inferImportGender(actor) || Object.keys(genders)[0], options: genders }; if (!entities.length || entities.includes('minecraft:player')) form[`slim_${index}`] = { type: 'checkbox', label: 'Slim player model', value: false, condition: (result) => result[`entity_${index}`] === 'minecraft:player' }; }); form.models_section = { type: 'info', text: '### Actor Models\nModels are selected automatically from the actor choices above, following local, imported, old default revision, and shared priority. Select a file only to override that lookup or supply a missing model.' }; const modelStatus = (index, values) => { const entity = values[`entity_${index}`], gender = values[`gender_${index}`]; if (!importResourceId(entity) || !['m', 'f', 'mf'].includes(gender)) return 'Choose an entity and model gender above.'; try { const resolved = resolveImportModel(source, registry.packs, entity, gender, definition.actors[index].entity_variant, io, !!values[`slim_${index}`]); return resolved ? `${resolved.kind}: ${resolved.file}` : 'No automatic model found. Select a model file below.'; } catch (error) { return `Model lookup unavailable: ${error.message}`; } }; const defaults = Object.fromEntries(Object.entries(form).filter(([, field]) => 'value' in field) .map(([key, field]) => [key, field.value])); definition.actors.forEach((_, index) => { form[`source_${index}`] = { type: 'text', label: `Actor ${index + 1} automatic model`, value: modelStatus(index, defaults), readonly: true, full_width: true }; form[`model_${index}`] = { type: 'file', label: `Actor ${index + 1} model override`, return_as: 'file', extensions: ['json'], readtype: 'text', filetype: 'GeckoLib geometry' }; }); const choices = await importDialog('Automatic Import: Actors & Models', form, 'Create Project and Import', function(result) { this.form.setValues(Object.fromEntries(definition.actors.map((_, index) => [`source_${index}`, modelStatus(index, result)])), false); }); if (!choices) return; const rows = definition.actors.map((actor, index) => ({ actor, label: choices[`clip_${index}`], entity: choices[`entity_${index}`], gender: choices[`gender_${index}`], slim: !!choices[`slim_${index}`] })); if (new Set(rows.map((row) => row.label)).size !== rows.length || rows.some((row) => !parsed.actorLabels.includes(row.label))) { throw new Error('Each definition actor must be assigned a distinct animation actor.'); } const selectedLabels = new Set(rows.map((row) => row.label)); const ignoredLabels = parsed.actorLabels.filter((label) => !selectedLabels.has(label)); // The definition owns the actor set; discard all tracks and effects belonging to unused clips. parsed.actorLabels = parsed.actorLabels.filter((label) => selectedLabels.has(label)); parsed.stages = parsed.stages.map((stage) => ({ ...stage, entries: stage.entries.filter((entry) => selectedLabels.has(entry.actorLabel)) })).filter((stage) => stage.entries.length); parsed.stages = resolveDefinitionImportStages(parsed, definition); // Preserve role references while relabeling the definition for the selected asset clips. const relabeled = clone(definition); relabeled.actors.forEach((actor, index) => { actor.label ||= keys[index]; }); const analysis = { actorIndices: rows.map((_, index) => index + 1), actorLabels: new Map(rows.map((row, index) => [index + 1, row.label])), stages: parsed.stages.map((stage) => ({ stageNumber: stage.stageNumber })) }; const labelMap = new Map(relabeled.actors.map((actor, index) => [actor.label, rows[index].label])); relabeled.actors.forEach((actor, index) => { actor.label = rows[index].label; if (actor.injects_into !== undefined) { const targets = Array.isArray(actor.injects_into) ? actor.injects_into : [actor.injects_into]; actor.injects_into = targets.map((target) => { if (!labelMap.has(target)) throw new Error(`Unknown injection target: ${target}`); return labelMap.get(target); }); } }); for (const stage of relabeled.stages || []) { if (stage.props && typeof stage.props === 'object' && !Array.isArray(stage.props)) { stage.props = Object.fromEntries(Object.entries(stage.props).map(([key, value]) => [labelMap.get(key) || key, value])); } } if (relabeled.position_anchor_actor !== undefined) relabeled.position_anchor_actor = labelMap.get(relabeled.position_anchor_actor) || relabeled.position_anchor_actor; const prepared = prepareAnimationDefinitionImport(relabeled, analysis); const warnings = registry.warnings.slice(); if (ignoredLabels.length) warnings.push(`Ignored animation actors not mapped to the definition: ${ignoredLabels.join(', ')}.`); for (const [index, row] of rows.entries()) { const override = choices[`model_${index}`]; row.resolved = override?.path ? { file: override.path, kind: 'Manually selected' } : resolveImportModel(source, registry.packs, row.entity, row.gender, row.actor.entity_variant, io, row.slim); if (!row.resolved) throw new Error(`No model found for ${row.label}. Select a model override for actor ${index + 1} and try again.`); row.json = readImportJson(row.resolved.file, io); row.geo = validateImportGeometry(row.json); loadImportTextures(row, registry.packs, io); const names = new Set(row.geo.bones.map((bone) => bone.name)); const missing = new Set(parsed.stages.flatMap((stage) => stage.entries.filter((entry) => entry.actorLabel === row.label) .flatMap((entry) => Object.keys(entry.animObj.bones || {}).filter((bone) => !names.has(bone))))); if (missing.size) warnings.push(`${row.label}: model lacks animated bones: ${[...missing].join(', ')}. Tracks will be retained.`); if (!row.textures.has(row.defaultTexture)) warnings.push(`${row.label}: no base texture found locally. Set its preview texture using Actor Preferences.`); } const imported = buildConjoinedImportFile(parsed, parsed.animationId, new Map(rows.map((row, index) => [row.label, index + 1]))); let blockEntry = null; if (prepared.block_requirements) { const fields = buildBlockRequirementForm(parsed.animationId, prepared.block_requirements); blockEntry = readBlockRequirementForm(Object.fromEntries(Object.entries(fields).filter(([, value]) => value.type !== 'info').map(([key, value]) => [key, value.value]))); buildBlockRequirementGuideBoxes(blockEntry); } const props = importPropPreviews(parsed.animationId, prepared, registry.packs.map((pack) => pack.root)); warnings.push(...props.warnings); commitAutomaticImport({ parsed, rows, definition: prepared, imported, blockEntry, propEntries: props.entries }); localStorage.setItem(PACK_FOLDERS_KEY, JSON.stringify(registry.packs.map((pack) => pack.root))); if (warnings.length) { console.warn(`[AnimationDirector] Import notes for ${parsed.animationId}:\n${warnings.join('\n')}`); const more = warnings.length > 1 ? ` (+${warnings.length - 1} more in Blockbench console)` : ''; Blockbench.showQuickMessage(`Imported with notes: ${warnings[0]}${more}`, 6000); } } catch (error) { Blockbench.showMessageBox({ title: 'Automatically Import Animation', message: error.message, buttons: ['OK'] }); } } // Sound IDs remain gameplay cues. Blob URLs are editor-only, lazily loaded audio previews. function stopSoundAudition() { if (soundAudition) { soundAudition.pause(); soundAudition = null; } } function clearSoundAudio() { stopSoundAudition(); for (const url of soundAudioUrls.values()) URL.revokeObjectURL(url); soundAudioUrls.clear(); soundAudioBytes = 0; } function soundJsonPaths(source, io) { if (source.files) return [...source.files.keys()].filter((name) => /^assets\/[^/]+\/sounds\.json$/.test(name)); const root = io.path.basename(source.path) === 'assets' ? io.path.dirname(source.path) : source.path; const assets = io.path.join(root, 'assets'); if (!io.fs.existsSync(assets)) return []; return io.fs.readdirSync(assets, { withFileTypes: true }).slice(0, 256) .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && /^[a-z0-9_.-]+$/.test(entry.name)) .map((entry) => `assets/${entry.name}/sounds.json`); } function loadSoundLibrary(io = desktopFiles()) { clearSoundAudio(); const events = new Map(), sources = [], warnings = []; const selected = localStorage.getItem(SOUND_SOURCE_KEY); const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : [])]); for (const file of paths.slice(0, 64)) { try { const source = openPropSource(file, io); sources.push(source); for (const name of soundJsonPaths(source, io)) { const raw = source.read(name); if (!raw) continue; const json = JSON.parse(raw.toString('utf8').replace(/^\uFEFF/, '')); const namespace = name.split('/')[1]; for (const [key, value] of Object.entries(json)) { const id = `${namespace}:${key}`; if (!importResourceId(id) || !Array.isArray(value?.sounds) || events.size >= 10000) continue; const entries = value.sounds.slice(0, 256); if (!events.has(id)) events.set(id, { entries, replace: value.replace === true }); else if (!events.get(id).replace) { events.get(id).entries.push(...entries); events.get(id).replace = value.replace === true; } } } } catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); } } soundLibrary = { events, sources, warnings }; soundLibraryGeneration++; return soundLibrary; } function ensureSoundLibrary() { if (!soundLibrary) { try { loadSoundLibrary(); } catch (_) { soundLibrary = { events: new Map(), sources: [], warnings: ['Sound assets require Blockbench Desktop.'] }; } } return soundLibrary; } function isPeakedSoundStage(animation) { if (typeof Project === 'undefined' || !Project) return false; const parsed = parseAfwStageAnimationName(animation); if (!parsed) return false; const stages = getSavedAnimdef(parsed.id).stages; return Array.isArray(stages) && stages.some((stage) => stage.stage === parsed.stageNumber && stage.non_peak === true); } function soundCueId(cue, animation) { const text = String(cue || '').trim(); const bare = text.replace(/^(?:needsofnature|minecraft):/, ''); if (bare.toLowerCase() === 'reactiveimpact_silent') return null; const semantic = { reactiveimpact: isPeakedSoundStage(animation) ? 'shot_in01' : 'impactdry01', impactdry: 'impactdry01', impactwet: 'impactwet01', birth: 'pop' }; if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`; const id = text.includes(':') ? text : `needsofnature:${text}`; return importResourceId(id) ? id : null; } function resolveSoundAudio(cue, library = ensureSoundLibrary(), visiting = new Set(), animation = null) { if (/^random\(/i.test(String(cue).trim())) { const match = String(cue).trim().match(/^random\((.*)\)$/i); if (!match) return null; for (const option of match[1].split(',').slice(0, 256)) { const audio = resolveSoundAudio(option, library, visiting, animation); if (audio) return audio; } return null; } const id = soundCueId(cue, animation); if (!id || visiting.has(id) || visiting.size >= 16) return null; const event = library.events.get(id); if (!event) return null; const next = new Set(visiting); next.add(id); for (const entry of event.entries) { const object = typeof entry === 'string' ? { name: entry } : entry; if (!object || typeof object.name !== 'string') continue; const name = object.name.includes(':') ? object.name : `minecraft:${object.name}`; const resource = importResourceId(name); if (!resource) continue; if (object.type === 'event') { const audio = resolveSoundAudio(name, library, next, animation); if (audio) return audio; continue; } const asset = `assets/${resource.namespace}/sounds/${resource.resource}.ogg`; if (soundAudioUrls.has(asset)) return soundAudioUrls.get(asset); for (const source of library.sources) { try { const bytes = source.read(asset); if (!bytes) continue; if (soundAudioBytes + bytes.length > 64 * 1024 * 1024) return null; const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/ogg' })); soundAudioUrls.set(asset, url); soundAudioBytes += bytes.length; return url; } catch (_) { /* An unavailable asset must never prevent animation import. */ } } } return null; } function mapAnimationSounds(animations) { for (const animation of animations || []) { const peaked = isPeakedSoundStage(animation); for (const keyframe of animation?.animators?.effects?.sound || []) { for (const point of keyframe.data_points || []) { if (point.file && point.file !== point[SOUND_PREVIEW_PROPERTY]) continue; const signature = `${soundLibraryGeneration}:${peaked}:${point.effect}:${point.sound}:${point.file}`; if (soundMappedPoints.get(point) === signature) continue; const library = ensureSoundLibrary(); const audio = resolveSoundAudio(point.effect, library, new Set(), animation) || (point.sound ? resolveSoundAudio(point.sound, library, new Set(), animation) : null); point.file = audio || ''; point[SOUND_PREVIEW_PROPERTY] = point.file; soundMappedPoints.set(point, `${soundLibraryGeneration}:${peaked}:${point.effect}:${point.sound}:${point.file}`); } } } } function applyLibrarySound(id, insert = false) { const animation = typeof Animation === 'undefined' ? null : Animation.selected; if (!animation) throw new Error('Select an animation first.'); const selected = (Timeline.selected || []).filter((keyframe) => keyframe.channel === 'sound' && keyframe.animator?.animation === animation); if (!insert && selected.length) { Undo.initEdit({ keyframes: selected }); for (const keyframe of selected) { const point = keyframe.data_points[0]; point.effect = id; point.file = ''; point[SOUND_PREVIEW_PROPERTY] = ''; } mapAnimationSounds([animation]); Undo.finishEdit('Apply sound from library'); } else { const effects = animation.animators.effects || (animation.animators.effects = new EffectAnimator(animation)); const keyframes = effects.sound.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001); Undo.initEdit({ keyframes }); let keyframe = keyframes[0]; if (keyframe) { keyframe.extend({ data_points: [...keyframe.data_points.map((point) => point.getUndoCopy()), { effect: id }] }); } else { keyframe = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'sound', false, false); keyframes.push(keyframe); } mapAnimationSounds([animation]); effects.select(); keyframe.select(); Undo.finishEdit('Insert sound from library', { keyframes }); } if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection(); } function showSoundLibrary() { stopSoundAudition(); ensureSoundLibrary(); soundLibraryDialog?.delete(); soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'NoN Sound Library', width: 700, buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition, component: { data() { return { query: '', category: 'all', selected: '', page: 0, ids: [...soundLibrary.events.keys()].sort(), warnings: soundLibrary.warnings.join('\n'), error: '' }; }, computed: { rows() { const cues = ['reactiveimpact', 'reactiveimpact_silent', 'impactdry', 'impactwet', 'birth']; return [...cues.map((id) => ({ id, category: 'Gameplay cues' })), ...this.ids.map((id) => ({ id, category: 'Sound events' }))] .filter((entry) => !SOUND_SELECTION_BLACKLIST.has(entry.id) && (this.category === 'all' || entry.category === this.category) && entry.id.toLowerCase().includes(this.query.toLowerCase())); }, visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); }, }, watch: { query() { this.page = 0; }, category() { this.page = 0; } }, methods: { reload() { try { loadSoundLibrary(); this.ids = [...soundLibrary.events.keys()].sort(); this.warnings = soundLibrary.warnings.join('\n'); this.error = ''; mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); } catch (error) { this.error = error.message; } }, folder() { try { const folder = Blockbench.pickDirectory({ title: 'Select folder containing assets', resource_id: SOUND_SOURCE_KEY }); if (folder) { localStorage.setItem(SOUND_SOURCE_KEY, folder); this.reload(); } } catch (error) { this.error = error.message; } }, jar() { Filesystem.importFile({ type: 'Sound assets', extensions: ['jar', 'zip'], readtype: 'none', multiple: false }, (files) => { if (files?.[0]?.path) { localStorage.setItem(SOUND_SOURCE_KEY, files[0].path); this.reload(); } }); }, preview() { stopSoundAudition(); this.error = ''; const file = resolveSoundAudio(this.selected, ensureSoundLibrary(), new Set(), typeof Animation === 'undefined' ? null : Animation.selected); if (!file) { this.error = this.selected === 'reactiveimpact_silent' ? 'Silent cue: gameplay effects only.' : 'No audio found. Select the NoN JAR or an extracted assets folder.'; return; } soundAudition = new Audio(file); soundAudition.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100)); soundAudition.play().catch((error) => { this.error = error.message; }); }, stop: stopSoundAudition, apply(insert) { try { applyLibrarySound(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } }, }, template: `

Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.

Gameplay cues and random events use representative audio, not a gameplay simulation.

{{row.id}}{{row.category}}
{{rows.length}} results

{{error}}

{{warnings}}

`, }, }); soundLibraryDialog.show(); } function isBrokenFiguraSoundWrapper(callback) { // Figura Format 0.1.5 discards in_loop. Do not patch native or corrected implementations. return typeof callback === 'function' && /^function\s*\(\s*\)\s*\{\s*if\s*\(\s*Format\s*===\s*format\s*\)\s*return\s*;?\s*displayFrame\.call\(\s*this\s*\)\s*;?\s*\}$/.test(Function.prototype.toString.call(callback)); } function removeFiguraSoundMedia(media) { for (const list of [Timeline.playing_sounds, Timeline.paused_sounds]) { const index = list?.indexOf(media) ?? -1; if (index >= 0) list.splice(index, 1); } figuraSoundMedia.delete(media); } function playFiguraCompatSounds(animator, inLoop, states) { const time = animator.animation?.time; if (!Number.isFinite(time)) return; const previous = states.get(animator); states.set(animator, { time, includeStart: false }); if (!inLoop || animator.muted?.sound || !Timeline.playing) return; const wrapped = previous && time < previous.time; const rangeStart = Timeline.custom_range?.[0] || 0; const rangeEnd = Math.min(animator.animation.length || Infinity, Timeline.custom_range?.[1] || Infinity); for (const frame of animator.sound || []) { const crossed = !previous ? frame.time === time : wrapped ? (frame.time > previous.time && frame.time <= rangeEnd) || (frame.time >= rangeStart && frame.time <= time) : frame.time <= time && (frame.time > previous.time || (previous.includeStart && frame.time === previous.time)); const file = frame.data_points?.[0]?.file; if (!crossed || !file) continue; const existing = Timeline.playing_sounds.filter((media) => media.keyframe_id === frame.uuid); // A sound resumed by Blockbench's startPreviousSounds must not be played twice. if (existing.length && !wrapped) continue; for (const media of existing) { media.pause(); removeFiguraSoundMedia(media); } try { const media = new Audio(file); media.keyframe_id = frame.uuid; media.playbackRate = Math.max(0.1, Math.min(4, (Timeline.playback_speed || 100) / 100)); media.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100)); media.onended = () => removeFiguraSoundMedia(media); Timeline.playing_sounds.push(media); figuraSoundMedia.add(media); media.play().catch((error) => { removeFiguraSoundMedia(media); console.warn('AnimationDirector sound preview failed:', error); }); } catch (error) { console.warn('AnimationDirector sound preview failed:', error); } } } function installFiguraSoundCompat() { if (typeof EffectAnimator === 'undefined') return; const original = EffectAnimator.prototype.displayFrame; if (!isBrokenFiguraSoundWrapper(original)) return; const states = new WeakMap(); const wrapper = function (...args) { const result = original.apply(this, args); if (figuraSoundCompatEnabled && typeof Format !== 'undefined' && Format.id === 'geckolib_model') { playFiguraCompatSounds(this, args[0], states); } return result; }; figuraSoundPatches.push({ original, wrapper, states }); EffectAnimator.prototype.displayFrame = wrapper; } function beginFiguraSoundPlayback() { installFiguraSoundCompat(); if (typeof Animation === 'undefined') return; mapAnimationSounds(Animation.all); for (const animation of Animation.all) { const effects = animation.animators?.effects; if (effects) for (const patch of figuraSoundPatches) { patch.states.set(effects, { time: animation.time, includeStart: true }); } } } function removeFiguraSoundCompat() { figuraSoundCompatEnabled = false; for (const media of [...figuraSoundMedia]) { media.pause(); removeFiguraSoundMedia(media); } for (const patch of figuraSoundPatches.slice().reverse()) { if (EffectAnimator.prototype.displayFrame === patch.wrapper) EffectAnimator.prototype.displayFrame = patch.original; } figuraSoundPatches.length = 0; } function refreshSoundLibraryPanel() { installFiguraSoundCompat(); if (typeof document !== 'undefined') { const panel = document.getElementById('panel_keyframe'); if (panel && !document.getElementById('animationdirector_sound_library_button')) { const button = document.createElement('button'); button.id = 'animationdirector_sound_library_button'; button.textContent = 'NoN Sound Library'; button.style.cssText = 'margin:8px;width:calc(100% - 16px)'; button.addEventListener('click', showSoundLibrary); panel.appendChild(button); } } mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]); } Plugin.register(PLUGIN_ID, { title: 'AnimationDirector', author: 'L1Z0', icon: 'groups', description: 'Multi-actor animation in Blockbench without bone-name clashes.', about: `AnimationDirector is a Blockbench plugin for the mod of the same name. It lets you import and animate multiple actors in one project without Blockbench auto-renaming bones when different models use the same bone names (like head, body, etc.). It does this by organizing each actor with an actorN_ prefix, so everything stays consistent. You can import or export complete AFW animation IDs as one file containing all stages and actors, or generate a configurable AFW animation definition from the current project.`, version: '2.0.0', variant: 'both', onload() { previousStagePoseAction = new Action('animationdirector_previous_stage_pose', { name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation', description: 'Copy the final actor pose of the previous stage into time 0.', click() { try { copyPreviousStagePose(); } catch (error) { Blockbench.showMessageBox({ title: 'Copy Previous Stage Pose', message: error.message }); } }, }); if (typeof Toolbars !== 'undefined' && Toolbars.timeline) { const toolbar = Toolbars.timeline; const marker = toolbar.children.findIndex((item) => item === 'add_marker' || item?.id === 'add_marker'); toolbar.add(previousStagePoseAction, marker >= 0 ? marker + 1 : 0); } for (const event of POSE_BUTTON_EVENTS) Blockbench.on(event, refreshPreviousStagePoseButton); previousStagePoseAction.node?.addEventListener('mouseenter', refreshPreviousStagePoseButton); refreshPreviousStagePoseButton(); if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') { soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, { exposed: false, condition: (point) => point.keyframe.channel === 'sound', }); } soundLibraryAction = new Action('animationdirector_sound_library', { name: 'NoN Sound Library', icon: 'library_music', category: 'animation', description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.', click: showSoundLibrary, }); soundLibraryListener = refreshSoundLibraryPanel; Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener); figuraSoundCompatEnabled = true; figuraSoundPlayListener = beginFiguraSoundPlayback; Blockbench.on('timeline_play', figuraSoundPlayListener); refreshSoundLibraryPanel(); if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) { if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) { actorNamesProjectProperty = new Property(ModelProject, 'string', ACTOR_NAMES_PROPERTY, { default: '{}', }); } if (!ModelProject.properties[ACTOR_TEXTURES_PROPERTY]) { actorTexturesProjectProperty = new Property(ModelProject, 'string', ACTOR_TEXTURES_PROPERTY, { default: '{}', }); } if (!ModelProject.properties[ACTOR_UV_BASIS_PROPERTY]) { actorUvBasisProjectProperty = new Property(ModelProject, 'string', ACTOR_UV_BASIS_PROPERTY, { default: '{}' }); } if (!ModelProject.properties[BONE_TEXTURE_OVERRIDES_PROPERTY]) { boneTextureOverridesProjectProperty = new Property(ModelProject, 'string', BONE_TEXTURE_OVERRIDES_PROPERTY, { default: '{}', }); } if (!ModelProject.properties[ACTOR_ANIMDEF_METADATA_PROPERTY]) { actorAnimdefMetadataProjectProperty = new Property(ModelProject, 'string', ACTOR_ANIMDEF_METADATA_PROPERTY, { default: '{}', }); } if (!ModelProject.properties[ANIMDEF_ANIMATORS_PROPERTY]) { animdefAnimatorsProjectProperty = new Property(ModelProject, 'string', ANIMDEF_ANIMATORS_PROPERTY, { default: '', }); } if (!ModelProject.properties[ANIMDEF_DEFINITIONS_PROPERTY]) { animdefDefinitionsProjectProperty = new Property(ModelProject, 'string', ANIMDEF_DEFINITIONS_PROPERTY, { default: '{}', }); } if (!ModelProject.properties[BLOCK_REQUIREMENTS_PROPERTY]) { blockRequirementsProjectProperty = new Property(ModelProject, 'string', BLOCK_REQUIREMENTS_PROPERTY, { default: '{}', }); } if (!ModelProject.properties[PROP_PREVIEWS_PROPERTY]) { propProjectProperty = new Property(ModelProject, 'string', PROP_PREVIEWS_PROPERTY, { default: '{}' }); } } blockGuidePreviewListener = ({ element }) => applyBlockRequirementGuideMaterial(element); Cube.preview_controller.on('setup update_faces', blockGuidePreviewListener); for (const cube of Cube.all) applyBlockRequirementGuideMaterial(cube); blockGuideProjectListener = refreshBlockGuideFaces; Blockbench.on('select_project', blockGuideProjectListener); refreshBlockGuideFaces(); blockRequirementUndoSaveListener = ({ save, aspects }) => { if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}'; if (aspects.animdef_edit) save[ANIMDEF_DEFINITIONS_PROPERTY] = Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}'; if (aspects.prop_preview_edit) save[PROP_PREVIEWS_PROPERTY] = Project[PROP_PREVIEWS_PROPERTY] || '{}'; if (aspects.actor_uv_edit) save[ACTOR_UV_BASIS_PROPERTY] = Project[ACTOR_UV_BASIS_PROPERTY] || '{}'; if (aspects.actor_texture_edit) save[ACTOR_TEXTURES_PROPERTY] = Project[ACTOR_TEXTURES_PROPERTY] || '{}'; if (aspects.actor_preferences_edit) { for (const property of [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, ACTOR_ANIMDEF_METADATA_PROPERTY, BONE_TEXTURE_OVERRIDES_PROPERTY, ACTOR_UV_BASIS_PROPERTY]) save[property] = Project[property] || '{}'; } if (aspects.automatic_animation_import) { for (const property of IMPORT_METADATA) save[property] = Project[property] ?? (property === ANIMDEF_ANIMATORS_PROPERTY ? '' : '{}'); } }; blockRequirementUndoLoadListener = ({ save }) => { if (save[BLOCK_REQUIREMENTS_PROPERTY] !== undefined) Project[BLOCK_REQUIREMENTS_PROPERTY] = save[BLOCK_REQUIREMENTS_PROPERTY]; if (save[ANIMDEF_DEFINITIONS_PROPERTY] !== undefined) Project[ANIMDEF_DEFINITIONS_PROPERTY] = save[ANIMDEF_DEFINITIONS_PROPERTY]; for (const property of IMPORT_METADATA) { if (save[property] !== undefined) Project[property] = save[property]; } scheduleActorTexturePreviewApply(); void refreshPropPreviews(true); }; Blockbench.on('create_undo_save', blockRequirementUndoSaveListener); Blockbench.on('load_undo_save', blockRequirementUndoLoadListener); propListener = () => { void refreshPropPreviews().catch((error) => { console.warn('Could not refresh item prop preview:', error); }); }; Blockbench.on('select_animation select_project load_project unselect_project undo redo render_frame', propListener); propAction = new Action('animationdirector_item_props', { name: 'Item Props', icon: 'interests', description: 'Preview item props on actor bones and configure default/per-stage animdef props.', click: showPropDialog, }); blockRequirementAction = new Action('animationdirector_add_block_requirement', { name: 'Block Requirements', description: 'Open the live block requirement panel with blue structure / red clearance guides.', icon: 'view_in_ar', click: addBlockRequirement, }); importAction = new Action('import_actor_gecko_json', { name: 'Import Actor', description: 'Import a GeckoLib/Bedrock geometry JSON into the current project and prefix bones with actorN_.', icon: 'person_add', click: importActorGeckoJson, }); replaceActorAction = new Action('replace_actor_gecko_json', { name: 'Replace Actor Model', description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.', icon: 'swap_horiz', click: replaceActorGeckoJson, }); importConjoinedAnimationAction = new Action('import_conjoined_actor_animation', { name: 'Import Animation', description: 'Import one conjoined AnimationDirector JSON and rebuild every stage and actor animation.', icon: 'playlist_add', click: importConjoinedAnimation, }); actorPreferencesAction = new Action('edit_actor_preferences', { name: 'Actor Preferences', description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.', icon: 'psychology', click: editActorPreferences, }); boneTextureOverridesAction = new Action('edit_bone_texture_overrides', { name: 'Bone Texture Overrides', description: 'Set recursive texture overrides for folders in any model project.', icon: 'texture', click: editBoneTextureOverrides, }); exportGeckoModelAction = new Action('animationdirector_export_gecko_model', { name: 'Export GeckoLib Model', description: 'Export the current model as GeckoLib/Bedrock geometry with AFW bone texture overrides.', icon: 'file_upload', click: exportGeckoModel, }); exportConjoinedAction = new Action('export_conjoined_actor_animations', { name: 'Export Animations', description: 'Select AFW animation IDs and export each as one file containing every stage and actor clip.', icon: 'file_download', click: showConjoinedAnimationExportDialog, }); createAnimationDefinitionAction = new Action('create_afw_animation_definition_stub', { name: 'Create Animation Definition', description: 'Generate an AFW animdef from one .p animation group.', icon: 'post_add', click: showCreateAnimationDefinitionDialog, }); importAnimationDefinitionAction = new Action('import_afw_animation_definition', { name: 'Import Animation Definition', description: 'Load generator fields and block requirement guides from an existing animdef JSON.', icon: 'assignment_add', click: importAnimationDefinition, }); automaticImportAction = new Action('animationdirector_automatic_import', { name: 'Automatically Import Animation', icon: 'add_box', description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.', click: automaticallyImportAnimation, }); packFoldersAction = new Action('animationdirector_pack_folders', { name: 'Pack Folders', icon: 'folder', description: 'Remember extracted packs and their model/texture lookup priority for automatic import.', click() { try { editImportPackFolders(); } catch (error) { Blockbench.showMessageBox({ title: 'Pack Folders', message: error.message, buttons: ['OK'] }); } }, }); MenuBar.menus.tools.addAction({ id: 'animationdirector_model', name: 'AnimationDirector Model', icon: 'deployed_code', children: [boneTextureOverridesAction, exportGeckoModelAction], }); MenuBar.menus.tools.addAction({ id: 'animationdirector_anims', name: 'AnimationDirector Anims', icon: 'movie', children: [ automaticImportAction, importAction, importConjoinedAnimationAction, importAnimationDefinitionAction, packFoldersAction, '_', actorPreferencesAction, replaceActorAction, '_', blockRequirementAction, propAction, soundLibraryAction, '_', createAnimationDefinitionAction, exportConjoinedAction, ], }); actorTexturePreviewProjectListener = scheduleActorTexturePreviewApply; for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) { Blockbench.on(eventName, actorTexturePreviewProjectListener); } scheduleActorTexturePreviewApply(); MenuBar.update(); }, onunload() { for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton); previousStagePoseAction?.delete(); previousStagePoseAction = undefined; Blockbench.removeListener('timeline_play', figuraSoundPlayListener); removeFiguraSoundCompat(); for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener); if (typeof document !== 'undefined') document.getElementById('animationdirector_sound_library_button')?.remove(); soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete(); clearSoundAudio(); soundLibrary = undefined; clearPropMeshes(); propSources.clear(); propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete(); if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener); propDialog = undefined; propListener = undefined; clearScheduledActorTexturePreviewApplies(); if (actorTexturePreviewProjectListener) { for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) { Blockbench.removeListener(eventName, actorTexturePreviewProjectListener); } actorTexturePreviewProjectListener = null; } MenuBar.menus.tools.removeAction('animationdirector_model'); MenuBar.menus.tools.removeAction('animationdirector_anims'); importAction.delete(); importConjoinedAnimationAction.delete(); actorPreferencesAction.delete(); boneTextureOverridesAction.delete(); exportGeckoModelAction.delete(); replaceActorAction.delete(); exportConjoinedAction.delete(); createAnimationDefinitionAction.delete(); importAnimationDefinitionAction.delete(); automaticImportAction.delete(); packFoldersAction.delete(); blockRequirementAction.delete(); Blockbench.removeListener('select_project', blockGuideProjectListener); cancelBlockRequirementUpdate(); if (blockRequirementPanelListener) { for (const event of ['select_project', 'unselect_project', 'undo', 'redo']) { Blockbench.removeListener(event, blockRequirementPanelListener); } } blockRequirementPanel?.delete(); blockRequirementPanelCss?.delete(); blockRequirementPanel = undefined; blockRequirementPanelListener = undefined; blockRequirementPanelCss = undefined; Cube.preview_controller.removeListener('setup', blockGuidePreviewListener); Cube.preview_controller.removeListener('update_faces', blockGuidePreviewListener); Blockbench.removeListener('create_undo_save', blockRequirementUndoSaveListener); Blockbench.removeListener('load_undo_save', blockRequirementUndoLoadListener); Canvas.updateAllFaces(); for (const material of blockGuideMaterials.values()) material.dispose(); blockGuideMaterials.clear(); if (actorNamesProjectProperty?.delete) actorNamesProjectProperty.delete(); if (actorTexturesProjectProperty?.delete) actorTexturesProjectProperty.delete(); if (boneTextureOverridesProjectProperty?.delete) boneTextureOverridesProjectProperty.delete(); if (actorAnimdefMetadataProjectProperty?.delete) actorAnimdefMetadataProjectProperty.delete(); if (animdefAnimatorsProjectProperty?.delete) animdefAnimatorsProjectProperty.delete(); if (animdefDefinitionsProjectProperty?.delete) animdefDefinitionsProjectProperty.delete(); if (blockRequirementsProjectProperty?.delete) blockRequirementsProjectProperty.delete(); }, }); })();