Files
AnimationDirector-Blockbenc…/AnimationDirector.js
T

3671 lines
133 KiB
JavaScript

(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';
const ANIMDEF_DEFINITIONS_PROPERTY = 'animationdirector_animdef_definitions';
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, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction;
let actorNamesProjectProperty, actorTexturesProjectProperty, 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 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 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));
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)];
} 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 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) => {
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 <animationId>.p<number> 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 <animationId>.p<number> 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 exportAnimationDefinitionStub(group, definition, actorMetadata) {
const mergedMetadata = { ...getProjectActorAnimdefMetadata(), ...actorMetadata };
setProjectActorAnimdefMetadata(mergedMetadata);
setProjectAnimdefAnimators(definition.animators || []);
saveAnimdef(group.id, definition);
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/<namespace>/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();
}
);
}
function showAnimationDefinitionReviewDialog(group, analysis) {
const savedMetadata = getProjectActorAnimdefMetadata();
const saved = getSavedAnimdef(group.id);
const savedStages = new Map((Array.isArray(saved.stages) ? saved.stages : []).map((stage) => [stage.stage, stage]));
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 it under data/<namespace>/afw_animdefs/. Your choices are stored in this Blockbench project for later exports.',
},
};
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 || 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' };
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 metadata = 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
? (isLastStage ? 'false' : 'default') : String(savedStage.escapable),
};
form[`${base}_peaked`] = {
label: 'Peaked stage',
type: 'checkbox',
value: savedStage.non_peak ?? 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 dialog = new Dialog({
id: 'create_afw_animation_definition_review',
title: `Create Animation Definition: ${group.id}`,
width: 760,
form,
buttons: ['Cancel', 'Create Animdef'],
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 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 = {};
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 = {};
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);
if (priority !== null && priority !== 0) definition.manual_peak = { priority };
}
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 = { 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 = {
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 <animationId>.p<number> 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 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: <anim>_<label>
const w = splitWeakBaseAndLabel(rawBase);
return {
rawBase,
strictActorIndex: null,
strictBaseName: null,
weakBaseName: w.base,
weakLabel: w.label,
};
}
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 parseSplitStageAnimationName(name) {
const match = /^(.+)\.p([1-9]\d*)$/.exec(String(name || '').trim());
if (!match) return null;
return {
animationId: match[1],
stageNumber: Number(match[2]),
};
}
function parseSplitStageFilename(rawBase) {
const match = /^(.+?\.p[1-9]\d*)_(.+)$/.exec(String(rawBase || '').trim());
if (!match) return null;
return {
stageName: match[1],
actorLabel: match[2],
};
}
function describeSplitFileActor(parts, animKey, fileIndex) {
const filenameIdentity = parseSplitStageFilename(parts.rawBase);
if (filenameIdentity) {
const actorMatch = /^actor([1-9]\d*)$/.exec(filenameIdentity.actorLabel);
const actorGuess = actorMatch ? Number(actorMatch[1]) : null;
return {
stageName: filenameIdentity.stageName,
key: actorGuess ? `actor:${actorGuess}` : `label:${filenameIdentity.actorLabel}`,
label: filenameIdentity.actorLabel,
actorGuess,
};
}
if (parts.strictActorIndex) {
return {
stageName: parts.strictBaseName || animKey,
key: `actor:${parts.strictActorIndex}`,
label: `actor${parts.strictActorIndex}`,
actorGuess: parts.strictActorIndex,
};
}
const exactPrefix = `${animKey}_`;
let label = parts.rawBase.startsWith(exactPrefix)
? parts.rawBase.slice(exactPrefix.length)
: parts.weakLabel;
label = String(label || '').trim();
if (!label) {
return {
stageName: animKey,
key: `file:${fileIndex}`,
label: parts.rawBase || `File ${fileIndex + 1}`,
actorGuess: null,
};
}
const actorMatch = /^actor([1-9]\d*)$/.exec(label);
return {
stageName: animKey,
key: `label:${label}`,
label,
actorGuess: actorMatch ? Number(actorMatch[1]) : null,
};
}
function analyzeSplitAnimationFiles(parsed) {
const stagesByName = new Map();
const actorsByKey = new Map();
for (let i = 0; i < parsed.length; i++) {
const entry = parsed[i];
const actor = describeSplitFileActor(entry.parts, entry.animKey, i);
entry.actorKey = actor.key;
entry.stageName = actor.stageName;
let stage = stagesByName.get(entry.stageName);
if (!stage) {
stage = {
animKey: entry.stageName,
parsedName: parseSplitStageAnimationName(entry.stageName),
firstFileIndex: i,
entries: [],
};
stagesByName.set(entry.stageName, stage);
}
if (stage.entries.some((stageEntry) => stageEntry.actorKey === actor.key)) {
throw new Error(`Stage "${entry.stageName}" contains more than one file for actor "${actor.label}".`);
}
stage.entries.push(entry);
if (!actorsByKey.has(actor.key)) {
actorsByKey.set(actor.key, { ...actor, fileCount: 1 });
} else {
actorsByKey.get(actor.key).fileCount++;
}
}
const stages = Array.from(stagesByName.values());
const parsedStageNames = stages.map((stage) => stage.parsedName);
const commonAnimationId = parsedStageNames.length > 0
&& parsedStageNames.every((stage) => stage && stage.animationId === parsedStageNames[0].animationId)
? parsedStageNames[0].animationId
: null;
if (commonAnimationId) {
stages.sort((a, b) => a.parsedName.stageNumber - b.parsedName.stageNumber);
} else {
stages.sort((a, b) => a.firstFileIndex - b.firstFileIndex);
}
return {
stages,
actors: Array.from(actorsByKey.values()),
commonAnimationId,
};
}
function buildSplitImportFile(analysis, actorMapping, requestedName, formatVersion) {
const animations = {};
const stageNames = [];
for (const stage of analysis.stages) {
let outputName;
if (analysis.commonAnimationId) {
outputName = `${requestedName}.p${stage.parsedName.stageNumber}`;
} else if (analysis.stages.length === 1) {
outputName = requestedName;
} else {
outputName = stage.animKey;
}
const mergeStage = {
entries: stage.entries.map((entry) => ({
actorLabel: entry.actorKey,
animObj: entry.animObj,
})),
};
animations[outputName] = mergeConjoinedStage(mergeStage, actorMapping, outputName);
stageNames.push(outputName);
}
return {
fileObj: {
format_version: formatVersion || '1.8.0',
animations,
},
stageNames,
};
}
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 <animationId>.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<number>_<actor name> 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 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);
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();
}
function importSplitActorAnimation() {
if (!Project) {
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
return;
}
if (typeof Animator?.loadFile !== 'function') {
Blockbench.showMessageBox({
title: 'Import Animation (Split Files)',
message: 'Animation importer not available in this Blockbench setup (missing Animator.loadFile).',
buttons: ['OK'],
});
return;
}
Filesystem.importFile(
{
type: 'GeckoLib Animation JSON (per actor)',
extensions: ['json'],
multiple: true,
readtype: 'text',
resource_id: 'animation',
title: 'Select split per-actor animation files to combine',
},
(files) => {
if (!files?.length) return;
try {
const parsed = [];
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
const f = files[fileIndex];
const text = f?.content;
if (typeof text !== 'string' || !text.trim()) throw new Error(`Could not read file contents: ${f?.name || '(unknown)'}`);
const json = JSON.parse(text);
const anims = json?.animations;
if (!anims || typeof anims !== 'object') throw new Error(`Missing "animations" map in ${f?.name || '(unknown)'}`);
const keys = Object.keys(anims);
if (keys.length !== 1) {
throw new Error(
`Expected exactly 1 animation clip inside ${f?.name || '(unknown)'} (found ${keys.length}). Each selected split file must contain one actor's clip for one stage.`
);
}
const animKey = keys[0];
const animObj = anims[animKey];
if (!animObj || typeof animObj !== 'object') throw new Error(`Invalid animation object in ${f?.name || '(unknown)'}`);
const parts = getFilenameParts(f?.name || '');
parsed.push({
file: f,
format_version: json?.format_version,
animKey,
animObj,
parts,
fileIndex,
});
}
const analysis = analyzeSplitAnimationFiles(parsed);
const actorOptions = buildActorSelectOptions();
const actorOptionKeys = Object.keys(actorOptions);
if (!actorOptionKeys.length) {
Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
return;
}
const canRename = analysis.commonAnimationId !== null || analysis.stages.length === 1;
const defaultName = analysis.commonAnimationId || analysis.stages[0]?.animKey || 'animation';
const form = {};
if (canRename) {
form.name = {
label: analysis.stages.length > 1 ? 'Animation ID' : 'Combined animation name',
type: 'text',
value: defaultName,
placeholder: analysis.stages.length > 1 ? 'wolfmplayer' : 'wolfmplayer.p1',
};
}
form._info = {
type: 'info',
text: analysis.stages.length > 1
? `Detected ${analysis.stages.length} stages. Map each file actor once; the mapping is reused for every stage.`
: "Pick which project actor each file actor belongs to. Bones receive that actor's 'actorN_' prefix.",
};
if (!canRename) {
form._stage_names = {
type: 'info',
text: `The selected files use unrelated animation names, so they will be preserved: ${analysis.stages.map((stage) => stage.animKey).join(', ')}`,
};
}
for (let i = 0; i < analysis.actors.length; i++) {
const actor = analysis.actors[i];
const guessedActor = String(actor.actorGuess || '');
form[`actor_${i}`] = {
label: `${actor.label} (${actor.fileCount} file${actor.fileCount === 1 ? '' : 's'})`,
type: 'select',
options: actorOptions,
value: Object.prototype.hasOwnProperty.call(actorOptions, guessedActor)
? guessedActor
: defaultActorOptionForLabel(actor.label, actorOptions),
};
}
const dialog = new Dialog({
id: 'import_combined_actor_animation',
title: 'Import Animation (Split Files)',
width: 720,
form,
buttons: ['Cancel', 'Import'],
cancelIndex: 0,
confirmIndex: 1,
onConfirm: (result) => {
try {
const requestedName = canRename
? String(result?.name || '').trim() || defaultName
: defaultName;
if (canRename && !requestedName) throw new Error('Animation name cannot be empty.');
const actorMapping = new Map();
const usedActorIndices = new Set();
for (let i = 0; i < analysis.actors.length; i++) {
const actor = analysis.actors[i];
const actorIndex = Number(result?.[`actor_${i}`]);
if (!Number.isFinite(actorIndex) || actorIndex <= 0) {
throw new Error(`Invalid project actor selected for "${actor.label}".`);
}
const normalizedActorIndex = Math.floor(actorIndex);
if (usedActorIndices.has(normalizedActorIndex)) {
throw new Error('Each file actor must map to a different project actor.');
}
actorMapping.set(actor.key, normalizedActorIndex);
usedActorIndices.add(normalizedActorIndex);
}
const imported = buildSplitImportFile(
analysis,
actorMapping,
requestedName,
parsed[0]?.format_version
);
const doImport = (nameOverrides = null) => {
const animations = {};
const finalNames = [];
for (const sourceName of imported.stageNames) {
const finalName = nameOverrides?.get(sourceName) || sourceName;
animations[finalName] = imported.fileObj.animations[sourceName];
finalNames.push(finalName);
}
const fakeFile = {
name: `${sanitizeFilePart(requestedName || 'split_animation_import')}.animation.json`,
path: '',
content: JSON.stringify({
format_version: imported.fileObj.format_version,
animations,
}, null, 2),
};
Animator.loadFile(fakeFile, finalNames);
Blockbench.showQuickMessage(
`Imported ${finalNames.length} stage${finalNames.length === 1 ? '' : 's'} from ${files.length} split files`,
2500
);
};
const existing = imported.stageNames.filter(animationNameExists);
if (existing.length) {
Blockbench.showMessageBox(
{
title: 'Import Animation (Split Files)',
message: `The following animations already exist:\n\n${existing.map((name) => `- ${name}`).join('\n')}`,
buttons: ['Cancel', 'Overwrite', 'Import Copies'],
},
(button) => {
if (button === 1 || button === 'Overwrite') {
for (const name of existing) removeAnimationByName(name);
doImport();
} else if (button === 2 || button === 'Import Copies') {
const usedNames = new Set(
(Array.isArray(Animation.all) ? Animation.all : [])
.map((animation) => animation?.name)
.filter(Boolean)
);
const copyNames = new Map();
for (const name of imported.stageNames) {
let copyName = name;
let suffix = 2;
while (usedNames.has(copyName)) copyName = `${name}_${suffix++}`;
usedNames.add(copyName);
copyNames.set(name, copyName);
}
doImport(copyNames);
}
}
);
} else {
doImport();
}
return true;
} catch (e) {
console.error(e);
Blockbench.showMessageBox({
title: 'Import Animation (Split Files)',
message: `Import failed: ${e?.message || e}`,
buttons: ['OK'],
});
return false;
}
},
});
dialog.show();
} catch (e) {
console.error(e);
Blockbench.showMessageBox({
title: 'Import Animation (Split Files)',
message: `Import failed: ${e?.message || e}`,
buttons: ['OK'],
});
}
}
);
}
// ----------------------------
// Register
// ----------------------------
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. The deprecated split-file import and export tools remain available for older projects.`,
version: '1.1.0',
variant: 'both',
onload() {
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[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: '{}',
});
}
}
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,
});
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,
});
importSplitAnimationAction = new Action('import_split_actor_animation', {
name: 'Import Animation (Split Files)',
description:
'Select split per-actor files from one or more stages, rebuild each stage, and re-apply actor prefixes.',
icon: 'playlist_add',
click: importSplitActorAnimation,
});
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: 'badge',
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,
});
exportSelectedAction = new Action('export_selected_actor_animation', {
name: 'Export Selected Animations (Split Files)',
description:
'Export selected animation as one <animation>_<actor name>.animation.json per actor, stripping actor prefixes from bone names.',
icon: 'file_download',
click: exportSelectedActorAnimation,
});
exportAllAction = new Action('export_all_actor_animations', {
name: 'Export All Animations (Split Files)',
description:
'Export every animation as one <animation>_<actor name>.animation.json per actor, stripping actor prefixes from bone names.',
icon: 'download_for_offline',
click: exportAllActorAnimations,
});
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 <animationId>.p<number> animation group.',
icon: 'post_add',
click: showCreateAnimationDefinitionDialog,
});
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: [
importAction,
importConjoinedAnimationAction,
actorPreferencesAction,
exportConjoinedAction,
createAnimationDefinitionAction,
{
id: 'animationdirector_deprecated',
name: 'Deprecated',
icon: 'history',
children: [importSplitAnimationAction, exportSelectedAction, exportAllAction],
},
],
});
actorTexturePreviewProjectListener = scheduleActorTexturePreviewApply;
for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
Blockbench.on(eventName, actorTexturePreviewProjectListener);
}
scheduleActorTexturePreviewApply();
MenuBar.update();
},
onunload() {
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();
importSplitAnimationAction.delete();
actorPreferencesAction.delete();
boneTextureOverridesAction.delete();
exportGeckoModelAction.delete();
exportSelectedAction.delete();
exportAllAction.delete();
exportConjoinedAction.delete();
createAnimationDefinitionAction.delete();
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();
},
});
})();