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