5255 lines
238 KiB
JavaScript
5255 lines
238 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 BLOCK_REQUIREMENTS_PROPERTY = 'animationdirector_block_requirements';
|
|
const BLOCK_GUIDE_PREFIX = '__ad_block_requirement_';
|
|
const BLOCK_GUIDE_COLORS = { required: 0x489dff, clearance: 0xff5555 };
|
|
const blockGuideMaterials = new Map();
|
|
let blockRequirementAction, blockRequirementsProjectProperty, blockGuidePreviewListener;
|
|
let blockRequirementUndoSaveListener, blockRequirementUndoLoadListener;
|
|
let blockRequirementPanel, blockRequirementPanelListener, blockRequirementPanelCss;
|
|
let blockRequirementPendingTimer;
|
|
let blockGuideProjectListener;
|
|
let importAnimationDefinitionAction;
|
|
let automaticImportAction;
|
|
let packFoldersAction;
|
|
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
|
|
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
|
|
let propAction, propProjectProperty, propDialog, propListener;
|
|
const propMeshes = new Map();
|
|
const propSources = new Map();
|
|
let propPreviewSignature = null, propPreviewGeneration = 0;
|
|
const SYSTEM_TAG_OPTIONS = {
|
|
'': 'None (normal animation)',
|
|
manualpeak: 'Manual peak',
|
|
defeated: 'Defeated',
|
|
stuck: 'Stuck',
|
|
tease: 'Tease',
|
|
birth: 'Birth',
|
|
fillbottle: 'Fill bottle',
|
|
other: 'Other',
|
|
};
|
|
|
|
let importAction, replaceActorAction, importConjoinedAnimationAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction;
|
|
let actorNamesProjectProperty, actorTexturesProjectProperty, 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 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?.();
|
|
}
|
|
|
|
// ----------------------------
|
|
// Block requirement authoring
|
|
// ----------------------------
|
|
function getBlockRequirementEntries() {
|
|
try {
|
|
const entries = JSON.parse(Project?.[BLOCK_REQUIREMENTS_PROPERTY] || '{}');
|
|
return entries && typeof entries === 'object' && !Array.isArray(entries) ? entries : {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function getAuthoredBlockRequirement(animationId) {
|
|
return getBlockRequirementEntries()[animationId] || null;
|
|
}
|
|
|
|
function blockRequirementInteger(value, label, minimum = 1) {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < minimum || parsed > 32) {
|
|
throw new Error(`${label} must be a whole number between ${minimum} and 32.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function readBlockRequirementForm(result) {
|
|
const blocks = uniqueTrimmedStrings(parseCommaSeparatedStrings(result.blocks));
|
|
for (const block of blocks) {
|
|
if (!/^#?[a-z0-9_.-]+:[a-z0-9/._-]+$/.test(block)) {
|
|
throw new Error(`Invalid block ID/tag "${block}". Use minecraft:stone or #minecraft:fences.`);
|
|
}
|
|
}
|
|
const requirement = { type: result.type };
|
|
if (blocks.length) requirement.blocks = blocks.length === 1 ? blocks[0] : blocks;
|
|
if (result.type === 'wall') {
|
|
const minHeight = blockRequirementInteger(result.height_min, 'Minimum height');
|
|
const minWidth = blockRequirementInteger(result.width_min, 'Minimum width');
|
|
const maxHeight = String(result.height_max ?? '').trim()
|
|
? blockRequirementInteger(result.height_max, 'Maximum height') : null;
|
|
const maxWidth = String(result.width_max ?? '').trim()
|
|
? blockRequirementInteger(result.width_max, 'Maximum width') : null;
|
|
if (maxHeight !== null && maxHeight < minHeight) throw new Error('Maximum height must be at least the minimum height.');
|
|
if (maxWidth !== null && maxWidth < minWidth) throw new Error('Maximum width must be at least the minimum width.');
|
|
requirement.height = { min: minHeight };
|
|
if (maxHeight !== null) requirement.height.max = maxHeight;
|
|
requirement.width = { min: minWidth };
|
|
if (maxWidth !== null) {
|
|
requirement.width.max = maxWidth;
|
|
if (result.both_axes) requirement.width.both_axes = true;
|
|
if (result.include_obstructions) requirement.width.include_obstructions = true;
|
|
}
|
|
if (result.facing) {
|
|
if (!blocks.length) throw new Error('A facing requirement needs a block ID or tag.');
|
|
requirement.facing = result.facing;
|
|
}
|
|
} else if (result.type === 'center_support') {
|
|
requirement.support = result.support;
|
|
if (result.support === 'surface' && !blocks.length) throw new Error('Custom surface support requires a block ID or tag.');
|
|
requirement.placement = result.placement;
|
|
if (result.placement === 'surface_footprint') {
|
|
requirement.surface_footprint = {
|
|
width: blockRequirementInteger(result.footprint_width, 'Footprint width'),
|
|
depth: blockRequirementInteger(result.footprint_depth, 'Footprint depth'),
|
|
height: blockRequirementInteger(result.footprint_height, 'Footprint clearance height'),
|
|
margin: blockRequirementInteger(result.footprint_margin, 'Footprint margin', 0),
|
|
};
|
|
} else {
|
|
requirement.surface_radius = blockRequirementInteger(result.surface_radius, 'Surface radius', 0);
|
|
}
|
|
} else {
|
|
throw new Error('Choose a wall or center support requirement.');
|
|
}
|
|
if (requirement.placement !== 'surface_footprint') {
|
|
requirement.clearance = {
|
|
width: blockRequirementInteger(result.clearance_width, 'Clearance width'),
|
|
height: blockRequirementInteger(result.clearance_height, 'Clearance height'),
|
|
depth: blockRequirementInteger(result.clearance_depth, 'Clearance depth'),
|
|
};
|
|
}
|
|
const wallThickness = Number(result.wall_thickness ?? 1);
|
|
const surfaceHeight = Number(result.surface_height ?? 1);
|
|
if (!Number.isFinite(wallThickness) || wallThickness <= 0 || wallThickness > 1) throw new Error('Preview wall thickness must be above 0 and at most 1 block.');
|
|
if (!Number.isFinite(surfaceHeight) || surfaceHeight <= 0 || surfaceHeight > 2) throw new Error('Preview surface height must be above 0 and at most 2 blocks.');
|
|
return { requirement, preview: { wallThickness, surfaceHeight } };
|
|
}
|
|
|
|
function buildBlockRequirementGuideBoxes(entry) {
|
|
const req = entry.requirement;
|
|
const boxes = [];
|
|
const add = (kind, from, to, label) => {
|
|
if (boxes.length >= 4096) throw new Error('This preview would exceed 4096 guide cubes. Reduce its dimensions.');
|
|
boxes.push({ kind, from: from.map((value) => value * 16), to: to.map((value) => value * 16), label });
|
|
};
|
|
const cell = (kind, x, y, z, height = 1, label = kind) =>
|
|
add(kind, [x, y, z], [x + 1, y + height, z + 1], label);
|
|
const clearance = req.clearance;
|
|
if (req.type === 'wall') {
|
|
const width = req.width.min;
|
|
const thickness = entry.preview?.wallThickness ?? 1;
|
|
// NoN places the anchor half a block away from the wall's collision surface.
|
|
const gridZ = -1 - thickness / 2;
|
|
const left = -width / 2;
|
|
for (let x = 0; x < width; x++) {
|
|
for (let y = 0; y < req.height.min; y++) {
|
|
add('required', [left + x, y, -0.5 - thickness], [left + x + 1, y + 1, -0.5], 'wall');
|
|
}
|
|
cell('required', left + x, -1, gridZ, 1, 'floor');
|
|
if (req.height.max === req.height.min) cell('clearance', left + x, req.height.max, gridZ, 1, 'height_cap');
|
|
}
|
|
const padding = clearance.width - 1;
|
|
for (let x = -padding; x < width + padding; x++) {
|
|
for (let z = 1; z <= clearance.depth; z++) {
|
|
for (let y = 0; y < clearance.height; y++) cell('clearance', left + x, y, gridZ + z);
|
|
cell('required', left + x, -1, gridZ + z, 1, 'floor');
|
|
}
|
|
}
|
|
} else {
|
|
const top = req.support === 'slab' || req.support === 'half_height' ? 0.5
|
|
: req.support === 'full_block' ? 1 : entry.preview?.surfaceHeight ?? 1;
|
|
if (req.placement === 'surface_footprint') {
|
|
const footprint = req.surface_footprint;
|
|
for (let x = -footprint.margin; x < footprint.width + footprint.margin; x++) {
|
|
for (let z = -footprint.margin; z < footprint.depth + footprint.margin; z++) {
|
|
if (x >= 0 && x < footprint.width && z >= 0 && z < footprint.depth) {
|
|
cell('required', x - 0.5, 0, z - 0.5, top, 'support');
|
|
}
|
|
for (let y = 0; y < footprint.height; y++) cell('clearance', x - 0.5, top + y, z - 0.5);
|
|
}
|
|
}
|
|
} else {
|
|
cell('required', -0.5, 0, -0.5, top, 'support');
|
|
const radius = req.surface_radius || 0;
|
|
for (let x = -radius; x <= radius; x++) {
|
|
for (let z = -radius; z <= radius; z++) {
|
|
for (let y = 0; y < clearance.height; y++) cell('clearance', x - 0.5, top + y, z - 0.5, 1, 'above_support');
|
|
}
|
|
}
|
|
const padding = clearance.width - 1;
|
|
for (let x = -padding; x <= padding; x++) {
|
|
for (let z = 1; z <= clearance.depth; z++) {
|
|
for (let y = 0; y < clearance.height; y++) cell('clearance', x - 0.5, y, z - 0.5);
|
|
cell('required', x - 0.5, -1, z - 0.5, 1, 'floor');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Merge overlapping clearance checks (surface-radius and directional checks can overlap).
|
|
const columns = new Map();
|
|
for (const box of boxes) {
|
|
const key = [box.kind, box.from[0], box.from[2], box.to[0], box.to[2]].join(':');
|
|
if (!columns.has(key)) columns.set(key, []);
|
|
columns.get(key).push(box);
|
|
}
|
|
const merged = [];
|
|
for (const column of columns.values()) {
|
|
column.sort((a, b) => a.from[1] - b.from[1]);
|
|
let current;
|
|
for (const box of column) {
|
|
if (current && box.from[1] < current.to[1]) current.to[1] = Math.max(current.to[1], box.to[1]);
|
|
else { current = { ...box, from: box.from.slice(), to: box.to.slice() }; merged.push(current); }
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function isBlockRequirementGuide(node) {
|
|
if (String(node?.name || '').startsWith(BLOCK_GUIDE_PREFIX)) return true;
|
|
return node?.getAllAncestors?.().some((parent) => String(parent.name || '').startsWith(BLOCK_GUIDE_PREFIX)) || false;
|
|
}
|
|
|
|
function applyBlockRequirementGuideMaterial(cube) {
|
|
if (!isBlockRequirementGuide(cube) || !cube.mesh || typeof THREE === 'undefined') return;
|
|
const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required';
|
|
let material = blockGuideMaterials.get(kind);
|
|
if (!material) {
|
|
material = new THREE.MeshBasicMaterial({ color: BLOCK_GUIDE_COLORS[kind], transparent: true,
|
|
opacity: 0.22, depthWrite: false, side: THREE.DoubleSide });
|
|
blockGuideMaterials.set(kind, material);
|
|
}
|
|
cube.mesh.material = material;
|
|
}
|
|
|
|
function hideInternalBlockGuideFaces(cubes) {
|
|
const faces = [['west', 0, 0], ['east', 0, 1], ['down', 1, 0], ['up', 1, 1], ['north', 2, 0], ['south', 2, 1]];
|
|
const planes = new Map();
|
|
const descriptors = [];
|
|
const epsilon = 0.000001;
|
|
for (const cube of cubes) {
|
|
const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required';
|
|
for (const [name, axis, positive] of faces) {
|
|
if (!cube.faces?.[name]) continue;
|
|
const axes = [0, 1, 2].filter((value) => value !== axis);
|
|
const rect = [cube.from[axes[0]], cube.from[axes[1]], cube.to[axes[0]], cube.to[axes[1]]];
|
|
const key = `${kind}:${axis}:${(positive ? cube.to[axis] : cube.from[axis]).toFixed(6)}`;
|
|
const descriptor = { cube, name, positive, rect, key };
|
|
if (!planes.has(key)) planes.set(key, [[], []]);
|
|
planes.get(key)[positive].push(descriptor);
|
|
descriptors.push(descriptor);
|
|
}
|
|
}
|
|
for (const face of descriptors) {
|
|
let uncovered = [face.rect];
|
|
// Subtract neighboring rectangles; several smaller faces can cover one large face.
|
|
for (const neighbor of planes.get(face.key)[1 - face.positive]) {
|
|
if (neighbor.cube === face.cube) continue;
|
|
const next = [];
|
|
for (const rect of uncovered) {
|
|
const left = Math.max(rect[0], neighbor.rect[0]), bottom = Math.max(rect[1], neighbor.rect[1]);
|
|
const right = Math.min(rect[2], neighbor.rect[2]), top = Math.min(rect[3], neighbor.rect[3]);
|
|
if (right - left <= epsilon || top - bottom <= epsilon) { next.push(rect); continue; }
|
|
if (left - rect[0] > epsilon) next.push([rect[0], rect[1], left, rect[3]]);
|
|
if (rect[2] - right > epsilon) next.push([right, rect[1], rect[2], rect[3]]);
|
|
if (bottom - rect[1] > epsilon) next.push([left, rect[1], right, bottom]);
|
|
if (rect[3] - top > epsilon) next.push([left, top, right, rect[3]]);
|
|
}
|
|
uncovered = next;
|
|
if (!uncovered.length) break;
|
|
}
|
|
// Null faces are omitted from Blockbench's mesh index, not just made transparent.
|
|
face.cube.faces[face.name].texture = uncovered.length ? false : null;
|
|
}
|
|
}
|
|
|
|
function refreshBlockGuideFaces() {
|
|
const roots = Group.all.filter((group) => group.name.startsWith(BLOCK_GUIDE_PREFIX)
|
|
&& !isBlockRequirementGuide(group.parent));
|
|
const elements = [];
|
|
for (const root of roots) {
|
|
const cubes = Cube.all.filter((cube) => cube.getAllAncestors().includes(root));
|
|
hideInternalBlockGuideFaces(cubes);
|
|
elements.push(...cubes);
|
|
}
|
|
if (elements.length) Canvas.updateView({ elements, element_aspects: { faces: true, uv: true } });
|
|
}
|
|
|
|
function rebuildBlockRequirementGuides(animationId, entry, importedDefinition, manageUndo = true) {
|
|
const boxes = entry ? buildBlockRequirementGuideBoxes(entry) : [];
|
|
const groupName = `${BLOCK_GUIDE_PREFIX}${animationId}`;
|
|
const oldGroups = Group.all.filter((group) => group.name === groupName);
|
|
const oldElements = Cube.all.filter((cube) => cube.getAllAncestors().some((group) => oldGroups.includes(group)));
|
|
const entries = getBlockRequirementEntries();
|
|
const updated = { ...entries };
|
|
if (entry) updated[animationId] = entry;
|
|
else updated[animationId] = { requirement: null };
|
|
const createdElements = [];
|
|
const createdGroups = [];
|
|
if (manageUndo) Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups,
|
|
block_requirement_edit: true, animdef_edit: importedDefinition !== undefined });
|
|
if (entry) {
|
|
const root = oldGroups[0] || new Group({ name: groupName, export: false });
|
|
if (!oldGroups.length) { root.init(); root.addTo('root'); }
|
|
createdGroups.push(root);
|
|
boxes.forEach((box, index) => {
|
|
const data = { name: `${BLOCK_GUIDE_PREFIX}${box.kind}_${box.label}_${index + 1}`,
|
|
from: box.from, to: box.to, export: false, box_uv: false };
|
|
const cube = oldElements[index] || new Cube(data);
|
|
if (oldElements[index]) {
|
|
cube.name = data.name;
|
|
cube.from = box.from.slice();
|
|
cube.to = box.to.slice();
|
|
cube.export = false;
|
|
} else { cube.init(); cube.addTo(root); }
|
|
createdElements.push(cube);
|
|
});
|
|
for (const cube of oldElements.slice(boxes.length)) cube.remove();
|
|
} else {
|
|
for (const group of oldGroups) group.remove();
|
|
}
|
|
Project[BLOCK_REQUIREMENTS_PROPERTY] = JSON.stringify(updated);
|
|
if (importedDefinition !== undefined) saveAnimdef(animationId, importedDefinition);
|
|
hideInternalBlockGuideFaces(createdElements);
|
|
// Rebuilding every cube would reset forced actor materials in single-texture formats.
|
|
Canvas.updateView({ elements: createdElements, groups: createdGroups,
|
|
element_aspects: { transform: true, geometry: true, faces: true, uv: true, visibility: true } });
|
|
for (const cube of createdElements) applyBlockRequirementGuideMaterial(cube);
|
|
if (manageUndo) Undo.finishEdit(importedDefinition !== undefined ? 'Import animation definition' : entry ? 'Set block requirement' : 'Remove block requirement',
|
|
{ outliner: true, elements: createdElements, groups: createdGroups, block_requirement_edit: true,
|
|
animdef_edit: importedDefinition !== undefined });
|
|
Project.saved = false;
|
|
}
|
|
|
|
function buildBlockRequirementForm(animationId, importedRequirement) {
|
|
const entry = getAuthoredBlockRequirement(animationId);
|
|
const req = importedRequirement ?? (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) ?? {};
|
|
const wall = (result) => result?.type === 'wall';
|
|
const center = (result) => result?.type === 'center_support';
|
|
const directional = (result) => wall(result) || result?.placement !== 'surface_footprint';
|
|
const footprint = (result) => center(result) && result?.placement === 'surface_footprint';
|
|
const rangeMin = (range, fallback) => typeof range === 'number' ? range : range?.min ?? fallback;
|
|
const rangeMax = (range) => typeof range === 'number' ? range : range?.max ?? '';
|
|
const dimension = (label, value, condition, min = 1) => ({ label, type: 'number', value, min, max: 32, step: 1, condition });
|
|
const form = {
|
|
_about: { type: 'info', text: `### Block requirement: ${animationId}\nBlue = required structure/floor. Red = clearance. `
|
|
+ 'Guides show the minimum valid layout at the animation origin (16 pixels = 1 block). Clearance extends toward +Z; actors face the wall toward -Z. '
|
|
+ 'Guides are excluded from model and animation exports; the requirement is included in Create Animation Definition.' },
|
|
type: { label: 'Requirement type', type: 'select', value: req.type || 'wall', options: { wall: 'Wall', center_support: 'Center support' } },
|
|
blocks: { label: 'Block IDs / tags', type: 'text', value: (Array.isArray(req.blocks) ? req.blocks : req.blocks ? [req.blocks] : []).join(', '),
|
|
placeholder: '#minecraft:fences, minecraft:stone', description: 'Comma-separated alternatives. Leave empty for any supported wall/support. Required for custom surfaces and facing.' },
|
|
_wall: { type: 'info', text: '### Wall structure', condition: wall },
|
|
height_min: dimension('Minimum height', rangeMin(req.height, req.height?.max != null ? 1 : 2), wall),
|
|
height_max: { label: 'Maximum height', type: 'text', value: rangeMax(req.height), placeholder: 'No limit', condition: wall },
|
|
width_min: dimension('Minimum width', rangeMin(req.width, 1), wall),
|
|
width_max: { label: 'Maximum width', type: 'text', value: req.width?.max ?? '', placeholder: 'No limit', condition: wall },
|
|
both_axes: { label: 'Maximum width: both axes', type: 'checkbox', value: req.width?.both_axes || false,
|
|
condition: (result) => wall(result) && !!String(result?.width_max || '').trim(), description: 'Also check the front/back axis at the required height levels.' },
|
|
include_obstructions: { label: 'Maximum width: count obstructions', type: 'checkbox', value: req.width?.include_obstructions || false,
|
|
condition: (result) => wall(result) && !!String(result?.width_max || '').trim(), description: 'Count any collidable neighbor, including blocks outside the IDs/tags above.' },
|
|
facing: { label: 'Required block face', type: 'select', value: req.facing || '', condition: wall,
|
|
options: { '': 'Any facing', front: 'Front', back: 'Back', left: 'Left', right: 'Right' }, description: 'The block face pointing toward the animation area. Stairs: the step face is Back.' },
|
|
_center: { type: 'info', text: '### Center support', condition: center },
|
|
support: { label: 'Support', type: 'select', value: req.support || 'full_block', condition: center,
|
|
options: { full_block: 'Full block', slab: 'Bottom slab', half_height: 'Bottom slab or bed', surface: 'Custom surface (IDs/tags)' } },
|
|
placement: { label: 'Placement', type: 'select', value: req.placement || 'directional_clearance', condition: center,
|
|
options: { directional_clearance: 'Directional clearance', surface_footprint: 'Surface footprint' } },
|
|
surface_radius: dimension('Surrounding surface radius', req.surface_radius ?? 0, (result) => center(result) && directional(result), 0),
|
|
footprint_width: dimension('Footprint width', req.surface_footprint?.width ?? 1, footprint),
|
|
footprint_depth: dimension('Footprint depth', req.surface_footprint?.depth ?? 1, footprint),
|
|
footprint_height: dimension('Clearance above footprint', req.surface_footprint?.height ?? 2, footprint),
|
|
footprint_margin: dimension('Free-space border', req.surface_footprint?.margin ?? 0, footprint, 0),
|
|
_clearance: { type: 'info', text: '### Directional clearance', condition: directional },
|
|
clearance_width: dimension('Clearance width', req.clearance?.width ?? 1, directional),
|
|
_width_hint: { type: 'info', text: 'Width 1 checks directly beside the support / across the matched wall. Width 2 adds one block on each side.', condition: directional },
|
|
clearance_height: dimension('Clearance height', req.clearance?.height ?? 2, directional),
|
|
clearance_depth: dimension('Clearance depth', req.clearance?.depth ?? 1, directional),
|
|
_preview: { type: 'info', text: '### Preview shape (editor only)\nActual collision shapes depend on the block state. These values adjust only the guide, not the exported requirement. Maximum limits/facing remain matching rules; the guide shows the minimum structure.' },
|
|
wall_thickness: { label: 'Wall thickness (blocks)', type: 'number', value: entry?.preview?.wallThickness ?? 1, min: 0.0625, max: 1, step: 0.0625, condition: wall,
|
|
description: 'Full block: 1. Fence post: 0.25. Adjust to the intended collision surface.' },
|
|
surface_height: { label: 'Custom surface height', type: 'number', value: entry?.preview?.surfaceHeight ?? 1, min: 0.0625, max: 2, step: 0.0625,
|
|
condition: (result) => center(result) && result?.support === 'surface', description: 'For example a bed is 0.5625 blocks high; slab/half-height mode uses 0.5.' },
|
|
};
|
|
return form;
|
|
}
|
|
|
|
function cancelBlockRequirementUpdate() {
|
|
if (blockRequirementPendingTimer !== undefined) clearTimeout(blockRequirementPendingTimer);
|
|
blockRequirementPendingTimer = undefined;
|
|
}
|
|
|
|
function blockRequirementAnimationIds() {
|
|
let groups = [];
|
|
try { groups = collectConjoinedAnimationGroups().groups; } catch (_) { /* Allow authoring before clips exist. */ }
|
|
return Array.from(new Set([...groups.map((group) => group.id), ...Object.keys(getBlockRequirementEntries())])).sort();
|
|
}
|
|
|
|
function createBlockRequirementPanel() {
|
|
blockRequirementPanelCss = Blockbench.addCSS(`
|
|
.ad-block-panel { padding: 10px; overflow-y: auto; height: 100%; box-sizing: border-box; }
|
|
.ad-block-panel p { margin: 6px 0; font-size: 12px; color: var(--color-text); opacity: .8; }
|
|
.ad-block-panel .ad-block-target { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
.ad-block-panel .ad-block-target input { width: 0; flex: 1; }
|
|
.ad-block-panel select { width: 100%; min-width: 0; }
|
|
.ad-block-panel details { margin: 10px 0; border: 1px solid var(--color-border); border-radius: 4px; padding: 8px; }
|
|
.ad-block-panel summary { font-weight: bold; cursor: pointer; color: var(--color-accent); }
|
|
.ad-block-panel .ad-block-field { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
|
|
.ad-block-panel .ad-block-field label { width: 100%; font-size: 12px; }
|
|
.ad-block-panel .ad-block-field input:not([type=checkbox]) { width: 100%; box-sizing: border-box; }
|
|
.ad-block-panel .ad-block-error { color: var(--color-error); opacity: 1; }
|
|
.ad-block-panel .ad-block-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
`);
|
|
blockRequirementPanel = new Panel('animationdirector_block_requirements', {
|
|
name: 'Block Requirements', icon: 'view_in_ar', condition: () => !!Project,
|
|
growable: true, resizable: true, min_height: 240, expand_button: true,
|
|
default_position: { slot: 'left_bar', height: 520, float_size: [360, 620] },
|
|
component: {
|
|
data() { return { projectId: null, animationId: '', draftId: '', ids: [], form: {}, values: {}, error: '', status: '', dirty: false }; },
|
|
computed: {
|
|
sections() {
|
|
const sections = [{ title: 'Requirement', fields: [] }];
|
|
for (const [key, field] of Object.entries(this.form)) {
|
|
if (key === '_about') continue;
|
|
if (field.condition && !field.condition(this.values)) continue;
|
|
if (field.type === 'info' && field.text.startsWith('### ')) {
|
|
sections.push({ title: field.text.slice(4).split('\n')[0], fields: [], hint: field.text.split('\n').slice(1).join('\n') });
|
|
} else sections[sections.length - 1].fields.push({ key, ...field });
|
|
}
|
|
return sections.filter((section) => section.fields.length);
|
|
},
|
|
},
|
|
methods: {
|
|
load(id) {
|
|
cancelBlockRequirementUpdate();
|
|
this.projectId = Project?.uuid || null;
|
|
this.ids = Project ? blockRequirementAnimationIds() : [];
|
|
this.animationId = id || '';
|
|
this.draftId = this.animationId;
|
|
this.form = Project && id ? buildBlockRequirementForm(id) : {};
|
|
this.values = Object.fromEntries(Object.entries(this.form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value]));
|
|
this.error = ''; this.dirty = false;
|
|
this.status = id ? 'Changes update the guides and are saved in this project automatically.' : 'Select an animation or enter a new animation ID.';
|
|
},
|
|
openId() {
|
|
const id = String(this.draftId).trim();
|
|
if (!/^[a-z0-9._-]+$/.test(id)) { this.error = 'Enter a valid lowercase animation ID (without the stage suffix).'; return; }
|
|
this.flush(); this.load(id);
|
|
},
|
|
selectId(event) { this.flush(); this.load(event.target.value); },
|
|
input(key, field, event) {
|
|
this.$set(this.values, key, field.type === 'checkbox' ? event.target.checked : event.target.value);
|
|
this.dirty = true;
|
|
cancelBlockRequirementUpdate();
|
|
try { buildBlockRequirementGuideBoxes(readBlockRequirementForm(this.values)); this.error = ''; }
|
|
catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; return; }
|
|
// Coalesce a typing/arrow-key burst without keeping a native Undo edit open.
|
|
blockRequirementPendingTimer = setTimeout(() => this.flush(), 180);
|
|
},
|
|
flush(force = false) {
|
|
cancelBlockRequirementUpdate();
|
|
if (!this.animationId || !Project || this.projectId !== Project.uuid || (!this.dirty && !force)) return;
|
|
try {
|
|
const entry = readBlockRequirementForm(this.values);
|
|
buildBlockRequirementGuideBoxes(entry);
|
|
const hasGuides = Group.all.some((group) => group.name === `${BLOCK_GUIDE_PREFIX}${this.animationId}`);
|
|
if (!hasGuides || JSON.stringify(entry) !== JSON.stringify(getAuthoredBlockRequirement(this.animationId))) {
|
|
rebuildBlockRequirementGuides(this.animationId, entry);
|
|
}
|
|
this.error = ''; this.dirty = false;
|
|
this.ids = blockRequirementAnimationIds();
|
|
this.status = 'Preview updated. Blue: structure / floor. Red: clearance. Undo is available.';
|
|
} catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; }
|
|
},
|
|
remove() {
|
|
cancelBlockRequirementUpdate();
|
|
if (!this.animationId || !Project || this.projectId !== Project.uuid) return;
|
|
rebuildBlockRequirementGuides(this.animationId, null);
|
|
this.load(this.animationId);
|
|
this.status = 'Requirement removed. Edit a value or click Build guides to add it again.';
|
|
},
|
|
},
|
|
template: `<div class="ad-block-panel">
|
|
<select :value="animationId" @change="selectId"><option value="">Select animation...</option><option v-for="id in ids" :key="id" :value="id">{{ id }}</option></select>
|
|
<div class="ad-block-target"><input v-model="draftId" placeholder="New animation ID" @keydown.enter="openId"><button @click="openId">Open</button></div>
|
|
<p>Live guides: 16 pixels = 1 block. Actors face -Z; wall clearance extends toward +Z. Guides are not exported.</p>
|
|
<p v-if="error" class="ad-block-error">{{ error }}</p><p v-else>{{ status }}</p>
|
|
<details v-for="section in sections" :key="section.title" open>
|
|
<summary>{{ section.title }}</summary><p v-if="section.hint">{{ section.hint }}</p>
|
|
<div v-for="field in section.fields" :key="field.key" class="ad-block-field">
|
|
<p v-if="field.type === 'info'">{{ field.text }}</p>
|
|
<template v-else><label :for="'ad-block-' + field.key">{{ field.label }}</label>
|
|
<select v-if="field.type === 'select'" :id="'ad-block-' + field.key" :value="values[field.key]" @change="input(field.key, field, $event); flush()">
|
|
<option v-for="(label, value) in field.options" :key="value" :value="value">{{ label }}</option>
|
|
</select>
|
|
<input v-else :id="'ad-block-' + field.key" :type="field.type === 'checkbox' ? 'checkbox' : field.type === 'number' ? 'number' : 'text'"
|
|
:value="values[field.key]" :checked="!!values[field.key]" :min="field.min" :max="field.max" :step="field.step" :placeholder="field.placeholder"
|
|
@input="input(field.key, field, $event)" @blur="flush()">
|
|
<p v-if="field.description">{{ field.description }}</p>
|
|
</template>
|
|
</div>
|
|
</details>
|
|
<div v-if="animationId" class="ad-block-actions"><button @click="flush(true)">Build guides</button><button @click="remove">Remove requirement</button></div>
|
|
</div>`,
|
|
},
|
|
});
|
|
blockRequirementPanelListener = () => {
|
|
cancelBlockRequirementUpdate();
|
|
const view = blockRequirementPanel?.vue;
|
|
if (view) view.load(Project && view.projectId === Project.uuid ? view.animationId : '');
|
|
};
|
|
Blockbench.on('select_project unselect_project undo redo', blockRequirementPanelListener);
|
|
}
|
|
|
|
function addBlockRequirement() {
|
|
if (!Project) return Blockbench.showQuickMessage('Open a project first.', 2500);
|
|
if (!blockRequirementPanel) createBlockRequirementPanel();
|
|
const view = blockRequirementPanel.vue;
|
|
view.flush();
|
|
const selected = parseAfwStageAnimationName(getSelectedAnimation());
|
|
const ids = blockRequirementAnimationIds();
|
|
view.load(selected?.id || (view.projectId === Project.uuid && view.animationId) || ids[0] || '');
|
|
if (blockRequirementPanel.slot === 'hidden') blockRequirementPanel.moveTo('left_bar');
|
|
blockRequirementPanel.folded = false;
|
|
blockRequirementPanel.getContainerPanel().selectTab(blockRequirementPanel);
|
|
blockRequirementPanel.update();
|
|
if (blockRequirementPanel.slot === 'float') blockRequirementPanel.moveToFront();
|
|
}
|
|
|
|
function stripBlockRequirementGuideBones(clip) {
|
|
if (clip?.bones) {
|
|
for (const name of Object.keys(clip.bones)) {
|
|
if (name.startsWith(BLOCK_GUIDE_PREFIX)) delete clip.bones[name];
|
|
}
|
|
}
|
|
return clip;
|
|
}
|
|
|
|
// ----------------------------
|
|
// Import Actor Geometry
|
|
// ----------------------------
|
|
function removeActorGeometry(actorIndex) {
|
|
const prefix = `actor${actorIndex}_`;
|
|
const roots = Group.all.filter((group) => {
|
|
if (!group?.name?.startsWith(prefix)) return false;
|
|
return !(group.parent instanceof Group && group.parent.name?.startsWith(prefix));
|
|
});
|
|
for (const root of roots) root.remove();
|
|
}
|
|
|
|
function captureActorAnimationBindings(actorIndex) {
|
|
const prefix = `actor${actorIndex}_`;
|
|
const bindings = new Map();
|
|
for (const group of Group.all || []) {
|
|
if (group?.name?.startsWith(prefix)) bindings.set(group.name, group.uuid);
|
|
}
|
|
// Removing a group deletes its animators, so retain the tracks before removal.
|
|
const animations = typeof Animation !== 'undefined' && Array.isArray(Animation.all) ? Animation.all : [];
|
|
return animations.map((animation) => ({
|
|
animation,
|
|
tracks: Array.from(bindings, ([boneName, uuid]) => ({ boneName, uuid, animator: animation.animators?.[uuid] }))
|
|
.filter((track) => track.animator),
|
|
})).filter((entry) => entry.tracks.length);
|
|
}
|
|
|
|
function rebindActorAnimations(actorIndex, previousBindings) {
|
|
if (!previousBindings?.length) {
|
|
return { rebound: 0, missing: 0 };
|
|
}
|
|
|
|
const prefix = `actor${actorIndex}_`;
|
|
const replacementGroups = new Map();
|
|
for (const group of Group.all || []) {
|
|
if (group?.name?.startsWith(prefix)) replacementGroups.set(group.name, group);
|
|
}
|
|
|
|
let rebound = 0;
|
|
let missing = 0;
|
|
for (const { animation, tracks } of previousBindings) {
|
|
const animators = animation.animators;
|
|
if (!animators || typeof animators !== 'object') continue;
|
|
|
|
for (const { boneName, uuid: oldUuid, animator } of tracks) {
|
|
const replacement = replacementGroups.get(boneName);
|
|
if (!replacement) {
|
|
animator._name = boneName;
|
|
animators[oldUuid] = animator;
|
|
animation.saved = false;
|
|
missing++;
|
|
continue;
|
|
}
|
|
|
|
if (typeof animation.removeAnimator === 'function') animation.removeAnimator(oldUuid);
|
|
else delete animators[oldUuid];
|
|
animator.uuid = replacement.uuid;
|
|
animator._name = replacement.name;
|
|
animators[replacement.uuid] = animator;
|
|
animation.saved = false;
|
|
rebound++;
|
|
}
|
|
}
|
|
|
|
return { rebound, missing };
|
|
}
|
|
|
|
function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false, manageUndo = true) {
|
|
const bones = Array.isArray(geo?.bones) ? geo.bones : [];
|
|
if (!bones.length) {
|
|
Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500);
|
|
return;
|
|
}
|
|
|
|
const prefix = `actor${actorIndex}_`;
|
|
const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null;
|
|
|
|
if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [],
|
|
animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] });
|
|
if (replaceExisting) removeActorGeometry(actorIndex);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
const animationResult = replaceExisting
|
|
? rebindActorAnimations(actorIndex, previousBindings)
|
|
: { rebound: 0, missing: 0 };
|
|
|
|
if (manageUndo) {
|
|
Canvas.updateAll();
|
|
applyAllActorTexturePreviews(false);
|
|
}
|
|
if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select();
|
|
if (manageUndo) Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`);
|
|
|
|
const animationNotice = replaceExisting && animationResult.rebound
|
|
? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}`
|
|
: '';
|
|
if (manageUndo) Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000);
|
|
|
|
if (replaceExisting && animationResult.missing) {
|
|
Blockbench.showMessageBox({
|
|
title: 'Replacement Model Missing Animated Bones',
|
|
message: `${animationResult.missing} existing animation channel${animationResult.missing === 1 ? '' : 's'} could not be rebound because the replacement model does not contain the matching actor bone. Those channels were left unchanged.`,
|
|
buttons: ['OK'],
|
|
});
|
|
}
|
|
}
|
|
|
|
function 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);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function replaceActorGeckoJson() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
const actorOptions = buildActorSelectOptions();
|
|
if (!Object.keys(actorOptions).length) {
|
|
Blockbench.showQuickMessage('No actorN_ model found in this project.', 2500);
|
|
return;
|
|
}
|
|
|
|
new Dialog({
|
|
id: 'replace_actor_gecko_json',
|
|
title: 'Replace Actor Model',
|
|
width: 520,
|
|
form: {
|
|
actor: {
|
|
label: 'Actor to replace',
|
|
type: 'select',
|
|
options: actorOptions,
|
|
value: Object.keys(actorOptions)[0],
|
|
},
|
|
_info: {
|
|
type: 'info',
|
|
text: 'The selected actor model will be replaced. Its actor prefix, animations, and actor preferences are preserved.',
|
|
},
|
|
},
|
|
buttons: ['Cancel', 'Choose Model'],
|
|
cancelIndex: 0,
|
|
confirmIndex: 1,
|
|
onConfirm: (result) => {
|
|
const actorIndex = Number(result?.actor);
|
|
if (!Number.isFinite(actorIndex) || actorIndex <= 0) {
|
|
Blockbench.showQuickMessage('Choose a valid actor.', 2500);
|
|
return false;
|
|
}
|
|
|
|
Filesystem.importFile(
|
|
{
|
|
type: 'GeckoLib / Bedrock Geometry JSON',
|
|
extensions: ['json', 'geo.json'],
|
|
multiple: false,
|
|
readtype: 'text',
|
|
resource_id: 'model',
|
|
title: 'Select replacement actor model',
|
|
},
|
|
(files) => {
|
|
if (!files?.length) return;
|
|
try {
|
|
const json = JSON.parse(files[0]?.content || '');
|
|
const geo = extractGeckoGeometryRoot(json);
|
|
if (!geo) {
|
|
Blockbench.showQuickMessage('Unsupported JSON structure (expected minecraft:geometry[0]).', 3000);
|
|
return;
|
|
}
|
|
importGeometryIntoCurrentProject(geo, actorIndex, true);
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showQuickMessage(`Replacement failed: ${e?.message || e}`, 4000);
|
|
}
|
|
}
|
|
);
|
|
return true;
|
|
},
|
|
}).show();
|
|
}
|
|
|
|
// ----------------------------
|
|
// Actor Preferences
|
|
// ----------------------------
|
|
function editActorPreferences() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
const actorIndices = getSortedUsedActorIndicesFromProject();
|
|
if (!actorIndices.length) {
|
|
Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
|
|
return;
|
|
}
|
|
|
|
const names = getProjectActorNames();
|
|
const textures = getProjectActorTextures();
|
|
const animdefMetadata = getProjectActorAnimdefMetadata();
|
|
const boneTextureOverrides = getProjectBoneTextureOverrides();
|
|
const textureOverrideGroups = getGroupsForTextureOverrides();
|
|
const loadedTextureNames = (Array.isArray(Texture.all) ? Texture.all : [])
|
|
.map((t) => getTextureDisplayName(t))
|
|
.filter((name) => name);
|
|
const textureOptions = buildTextureSelectOptions(textures, boneTextureOverrides);
|
|
let showBoneTextureOverrides = false;
|
|
let showAnimdefMetadata = false;
|
|
|
|
const form = {
|
|
_info: {
|
|
type: 'info',
|
|
text:
|
|
'Set export names, actor preview textures, animation-definition metadata, and folder texture overrides for this project. Folder overrides apply recursively and take priority over actor textures. Loaded textures: ' +
|
|
(loadedTextureNames.length ? loadedTextureNames.join(', ') : '(none)'),
|
|
},
|
|
};
|
|
|
|
for (const idx of actorIndices) {
|
|
form[`name_${idx}`] = {
|
|
label: `actor${idx} export name`,
|
|
type: 'text',
|
|
value: names[String(idx)] || '',
|
|
placeholder: `actor${idx}`,
|
|
};
|
|
form[`texture_${idx}`] = {
|
|
label: `actor${idx} preview texture`,
|
|
type: 'select',
|
|
options: textureOptions,
|
|
value: textures[String(idx)] || '',
|
|
};
|
|
}
|
|
|
|
form.show_animdef_metadata = {
|
|
label: 'Animation definition metadata',
|
|
type: 'checkbox',
|
|
style: 'toggle_switch',
|
|
value: showAnimdefMetadata,
|
|
description: 'Show persistent AFW actor constraints used by animation-definition exports.',
|
|
};
|
|
form._animdef_info = {
|
|
type: 'info',
|
|
text: 'AFW actor metadata',
|
|
condition: (result) => result?.show_animdef_metadata ?? showAnimdefMetadata,
|
|
};
|
|
actorIndices.forEach((idx, actorPosition) => {
|
|
const label = names[String(idx)] || `actor${idx}`;
|
|
const metadata = actorAnimdefMetadataWithInference(idx, animdefMetadata[String(idx)]);
|
|
addActorAnimdefFormFields(
|
|
form,
|
|
'preferences_animdef',
|
|
idx,
|
|
label,
|
|
metadata,
|
|
actorPosition === 0 ? 'active' : 'passive',
|
|
(result) => result?.show_animdef_metadata ?? showAnimdefMetadata,
|
|
actorIndices.map((actor) => names[String(actor)] || `actor${actor}`)
|
|
);
|
|
});
|
|
|
|
if (textureOverrideGroups.length) {
|
|
form.show_bone_texture_overrides = {
|
|
label: 'Folder texture overrides',
|
|
type: 'checkbox',
|
|
style: 'toggle_switch',
|
|
value: showBoneTextureOverrides,
|
|
description: 'Show recursive folder-level texture overrides. These take priority over actor textures.',
|
|
};
|
|
form._bone_info = {
|
|
type: 'info',
|
|
text: 'Folder texture overrides',
|
|
condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides,
|
|
};
|
|
}
|
|
|
|
for (let i = 0; i < textureOverrideGroups.length; i++) {
|
|
const group = textureOverrideGroups[i];
|
|
const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides);
|
|
form[`bone_texture_${i}`] = {
|
|
label: getGroupTextureOverrideLabel(group),
|
|
type: 'select',
|
|
options: textureOptions,
|
|
value: saved?.texture || '',
|
|
condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides,
|
|
};
|
|
}
|
|
|
|
const dialog = new Dialog({
|
|
id: 'edit_actor_preferences',
|
|
title: 'Actor Preferences',
|
|
width: 720,
|
|
form,
|
|
buttons: ['Cancel', 'Save & Apply'],
|
|
cancelIndex: 0,
|
|
confirmIndex: 1,
|
|
onFormChange: (result) => {
|
|
showBoneTextureOverrides = !!result?.show_bone_texture_overrides;
|
|
showAnimdefMetadata = !!result?.show_animdef_metadata;
|
|
},
|
|
onConfirm: (result) => {
|
|
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 && !isBlockRequirementGuide(group)) : [];
|
|
return groups.sort((a, b) => String(a?.name || '').localeCompare(String(b?.name || '')));
|
|
}
|
|
|
|
function getGroupTextureOverrideKey(group) {
|
|
return String(group?.uuid || group?.name || '').trim();
|
|
}
|
|
|
|
function getGroupTextureOverrideLabel(group) {
|
|
return String(group?.name || '(unnamed folder)').trim();
|
|
}
|
|
|
|
function getBoneTextureOverrideForGroup(group, overrides) {
|
|
if (!group || !overrides) return null;
|
|
|
|
const key = getGroupTextureOverrideKey(group);
|
|
const direct = key ? overrides[key] : null;
|
|
if (direct?.texture) return direct;
|
|
|
|
const name = String(group.name || '').trim();
|
|
if (!name) return null;
|
|
|
|
return Object.values(overrides).find((override) => override?.name === name && override?.texture) || null;
|
|
}
|
|
|
|
function cubeBelongsToActor(cube, actorIndex) {
|
|
const prefix = `actor${actorIndex}_`;
|
|
if (typeof cube?.name === 'string' && cube.name.startsWith(prefix)) return true;
|
|
return groupBelongsToActor(cube?.parent, actorIndex);
|
|
}
|
|
|
|
function getTextureMaterial(texture) {
|
|
if (!texture) return null;
|
|
if (typeof texture.getMaterial === 'function') return texture.getMaterial();
|
|
if (typeof texture.getOwnMaterial === 'function') return texture.getOwnMaterial();
|
|
return texture.material || null;
|
|
}
|
|
|
|
function forceActorTextureMaterial(cube, texture) {
|
|
const material = getTextureMaterial(texture);
|
|
if (!cube?.mesh || !material) return false;
|
|
|
|
cube.mesh.material = material;
|
|
cube.mesh.material.needsUpdate = true;
|
|
cube.mesh.needsUpdate = true;
|
|
cube.mesh.userData = cube.mesh.userData || {};
|
|
cube.mesh.userData.multiactor_actor_texture_uuid = texture.uuid || texture.id || texture.name;
|
|
return true;
|
|
}
|
|
|
|
function applyTextureToCube(cube, texture) {
|
|
if (!cube || !texture) return;
|
|
|
|
const 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);
|
|
if (isBlockRequirementGuide(group) || String(override?.name || '').startsWith(BLOCK_GUIDE_PREFIX)) continue;
|
|
const boneName = String(group?.name || override?.name || '').trim();
|
|
const texturePath = getTextureExportPath(override?.texture);
|
|
if (boneName && texturePath) {
|
|
out[boneName] = texturePath;
|
|
explicitBones.add(boneName);
|
|
}
|
|
}
|
|
|
|
const cubes = Array.isArray(Cube.all) ? Cube.all : [];
|
|
const textureCounts = {};
|
|
const boneTextureCounts = {};
|
|
|
|
for (const cube of cubes) {
|
|
if (isBlockRequirementGuide(cube)) continue;
|
|
const group = cube?.parent instanceof Group ? cube.parent : null;
|
|
const boneName = String(group?.name || '').trim();
|
|
const texturePath = getReadableTexturePathFromCube(cube);
|
|
if (!boneName || !texturePath) continue;
|
|
|
|
textureCounts[texturePath] = (textureCounts[texturePath] || 0) + 1;
|
|
boneTextureCounts[boneName] = boneTextureCounts[boneName] || {};
|
|
boneTextureCounts[boneName][texturePath] = (boneTextureCounts[boneName][texturePath] || 0) + 1;
|
|
}
|
|
|
|
let baseTexture = '';
|
|
let baseCount = 0;
|
|
for (const [texturePath, count] of Object.entries(textureCounts)) {
|
|
if (count > baseCount) {
|
|
baseTexture = texturePath;
|
|
baseCount = count;
|
|
}
|
|
}
|
|
|
|
for (const [boneName, counts] of Object.entries(boneTextureCounts)) {
|
|
if (explicitBones.has(boneName)) continue;
|
|
|
|
let dominantTexture = '';
|
|
let dominantCount = 0;
|
|
for (const [texturePath, count] of Object.entries(counts)) {
|
|
if (count > dominantCount) {
|
|
dominantTexture = texturePath;
|
|
dominantCount = count;
|
|
}
|
|
}
|
|
|
|
if (dominantTexture && dominantTexture !== baseTexture) out[boneName] = dominantTexture;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function addAfwBoneTextureMapToGeckoJson(json, afwBoneTextures) {
|
|
if (!Object.keys(afwBoneTextures || {}).length) return json;
|
|
|
|
const output = {};
|
|
output.afw_bone_textures = afwBoneTextures;
|
|
|
|
for (const [key, value] of Object.entries(json || {})) {
|
|
if (key !== 'afw_bone_textures') output[key] = value;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function stringifyCompactModelJson(obj, indent = 2) {
|
|
let i = 0;
|
|
const tokenPrefix = '__COMPACT_MODEL_JSON__';
|
|
const replacements = new Map();
|
|
|
|
const isNumericArray = (value) =>
|
|
Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
|
|
const isCompactCube = (value) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
|
|
const keys = Object.keys(value);
|
|
if (!keys.length) return false;
|
|
|
|
const compactKeys = new Set(['origin', 'size', 'pivot', 'rotation', 'uv', 'inflate', 'mirror']);
|
|
for (const key of keys) {
|
|
const child = value[key];
|
|
if (!compactKeys.has(key)) return false;
|
|
if (Array.isArray(child)) {
|
|
if (!isNumericArray(child)) return false;
|
|
} else if (typeof child !== 'number' && typeof child !== 'boolean') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return Array.isArray(value.origin) && Array.isArray(value.size);
|
|
};
|
|
|
|
const isCompactUvFace = (value) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
|
|
const keys = Object.keys(value);
|
|
if (!keys.length || keys.length > 3) return false;
|
|
if (!Array.isArray(value.uv) || !Array.isArray(value.uv_size)) return false;
|
|
|
|
for (const key of keys) {
|
|
if (key === 'uv' || key === 'uv_size') {
|
|
if (!isNumericArray(value[key])) return false;
|
|
} else if (key !== 'texture') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const inline = (value) => {
|
|
if (isNumericArray(value)) return `[${value.join(', ')}]`;
|
|
|
|
const parts = [];
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childText = isNumericArray(child) ? `[${child.join(', ')}]` : JSON.stringify(child);
|
|
parts.push(`${JSON.stringify(key)}: ${childText}`);
|
|
}
|
|
return `{${parts.join(', ')}}`;
|
|
};
|
|
|
|
const json = JSON.stringify(
|
|
obj,
|
|
(key, value) => {
|
|
if (isCompactCube(value) || isCompactUvFace(value) || isNumericArray(value)) {
|
|
const token = `${tokenPrefix}${i++}__`;
|
|
replacements.set(token, inline(value));
|
|
return token;
|
|
}
|
|
return value;
|
|
},
|
|
indent
|
|
);
|
|
|
|
let out = json;
|
|
for (const [token, text] of replacements) out = out.split(`"${token}"`).join(text);
|
|
return out;
|
|
}
|
|
|
|
function getGeckoModelCodecCandidates() {
|
|
const candidates = [];
|
|
const add = (codec) => {
|
|
if (codec && typeof codec.compile === 'function' && !candidates.includes(codec)) candidates.push(codec);
|
|
};
|
|
|
|
add(Format?.codec);
|
|
add(Codecs?.bedrock);
|
|
add(Codecs?.bedrock_old);
|
|
|
|
if (Codecs && typeof Codecs === 'object') {
|
|
for (const codec of Object.values(Codecs)) {
|
|
const name = String(codec?.name || codec?.id || '').toLowerCase();
|
|
if (name.includes('gecko') || name.includes('bedrock')) add(codec);
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function parseCompiledModelContent(content) {
|
|
if (typeof content === 'string') return JSON.parse(content);
|
|
if (content && typeof content === 'object') return clone(content);
|
|
throw new Error('Model compiler returned empty content.');
|
|
}
|
|
|
|
function compileGeckoModelJson() {
|
|
const candidates = getGeckoModelCodecCandidates();
|
|
let lastError = null;
|
|
|
|
for (const codec of candidates) {
|
|
try {
|
|
const options = typeof codec.getExportOptions === 'function' ? codec.getExportOptions() : undefined;
|
|
const json = parseCompiledModelContent(codec.compile(options));
|
|
if (Array.isArray(json?.['minecraft:geometry'])) {
|
|
for (const geometry of json['minecraft:geometry']) {
|
|
const excluded = new Set();
|
|
const bones = geometry.bones || [];
|
|
for (const bone of bones) if (bone.name?.startsWith(BLOCK_GUIDE_PREFIX)) excluded.add(bone.name);
|
|
// Also exclude descendants if a creator has renamed a guide bone.
|
|
let previousSize;
|
|
do {
|
|
previousSize = excluded.size;
|
|
for (const bone of bones) if (excluded.has(bone.parent)) excluded.add(bone.name);
|
|
} while (excluded.size !== previousSize);
|
|
geometry.bones = bones.filter((bone) => !excluded.has(bone.name));
|
|
}
|
|
return json;
|
|
}
|
|
} catch (e) {
|
|
lastError = e;
|
|
}
|
|
}
|
|
|
|
throw new Error(lastError?.message || 'Could not find a GeckoLib/Bedrock model compiler for this project.');
|
|
}
|
|
|
|
function getModelExportFileName() {
|
|
const codec = Format?.codec;
|
|
const codecName = typeof codec?.fileName === 'function' ? codec.fileName() : '';
|
|
const name = codecName || Project?.geometry_name || Project?.name || 'model';
|
|
return sanitizeFilePart(String(name).replace(/\.geo\.json$/i, '').replace(/\.json$/i, '')) || 'model';
|
|
}
|
|
|
|
function exportGeckoModel() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const compiled = compileGeckoModelJson();
|
|
const afwBoneTextures = buildAfwBoneTextureMap();
|
|
const output = addAfwBoneTextureMapToGeckoJson(compiled, afwBoneTextures);
|
|
|
|
Blockbench.export(
|
|
{
|
|
type: 'GeckoLib Model JSON',
|
|
extensions: ['json', 'geo.json'],
|
|
name: `${getModelExportFileName()}.geo`,
|
|
content: stringifyCompactModelJson(output, 2),
|
|
savetype: 'text',
|
|
},
|
|
() => Blockbench.showQuickMessage('Exported GeckoLib model', 2500)
|
|
);
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showMessageBox({
|
|
title: 'Export GeckoLib Model',
|
|
message: `Export failed: ${e?.message || e}`,
|
|
buttons: ['OK'],
|
|
});
|
|
}
|
|
}
|
|
|
|
// ----------------------------
|
|
// Export Actor Animations
|
|
// ----------------------------
|
|
function getSelectedAnimation() {
|
|
return Animation.selected || Timeline.animation || Animation.all?.[0] || null;
|
|
}
|
|
|
|
function stripPrefixFromAnimationFileForActor(fileObj, actorIndex) {
|
|
const prefix = `actor${actorIndex}_`;
|
|
const anims = fileObj?.animations;
|
|
if (!anims) return;
|
|
|
|
for (const animKey of Object.keys(anims)) {
|
|
const anim = anims[animKey];
|
|
if (!anim) continue;
|
|
|
|
if (anim.bones) {
|
|
const newBones = {};
|
|
for (const [boneName, payload] of Object.entries(anim.bones)) {
|
|
if (boneName.startsWith(prefix)) newBones[boneName.slice(prefix.length)] = payload;
|
|
}
|
|
anim.bones = newBones;
|
|
}
|
|
|
|
// De-prefix common string fields (locators/particles)
|
|
const fixStrings = (node) => {
|
|
if (!node || typeof node !== 'object') return;
|
|
for (const k of Object.keys(node)) {
|
|
const v = node[k];
|
|
if (typeof v === 'string') {
|
|
if ((k === 'bone' || k === 'locator') && v.startsWith(prefix)) node[k] = v.slice(prefix.length);
|
|
} else if (v && typeof v === 'object') {
|
|
fixStrings(v);
|
|
}
|
|
}
|
|
};
|
|
fixStrings(anim);
|
|
}
|
|
}
|
|
|
|
function stringifyInlineVec3(obj, indent = 2) {
|
|
let i = 0;
|
|
const tokenPrefix = '__INLINE_VEC3__';
|
|
const replacements = new Map();
|
|
|
|
const json = JSON.stringify(
|
|
obj,
|
|
(key, value) => {
|
|
if (Array.isArray(value) && value.length === 3 && value.every((n) => typeof n === 'number' && Number.isFinite(n))) {
|
|
const token = `${tokenPrefix}${i++}__`;
|
|
replacements.set(token, `[${value[0]},${value[1]},${value[2]}]`);
|
|
return token;
|
|
}
|
|
return value;
|
|
},
|
|
indent
|
|
);
|
|
|
|
let out = json;
|
|
for (const [token, vecStr] of replacements) out = out.split(`"${token}"`).join(vecStr);
|
|
return out;
|
|
}
|
|
|
|
function exportAnimationOutputs(outputs, doneMessage) {
|
|
let idx = 0;
|
|
const next = () => {
|
|
if (idx >= outputs.length) {
|
|
Blockbench.showQuickMessage(doneMessage || `Exported ${outputs.length} actor animation files`, 2500);
|
|
return;
|
|
}
|
|
const o = outputs[idx++];
|
|
Blockbench.export(
|
|
{
|
|
type: 'JSON',
|
|
extensions: ['json'],
|
|
name: o.name,
|
|
content: o.content,
|
|
savetype: 'text',
|
|
},
|
|
next
|
|
);
|
|
};
|
|
next();
|
|
}
|
|
|
|
function parseAfwStageAnimationName(animation) {
|
|
const name = String(animation?.name || '').trim();
|
|
const match = /^([a-z0-9._-]+)\.p(\d+)$/.exec(name);
|
|
if (!match) return null;
|
|
return {
|
|
id: match[1],
|
|
stageNumber: Number(match[2]),
|
|
name,
|
|
animation,
|
|
};
|
|
}
|
|
|
|
function collectConjoinedAnimationGroups() {
|
|
const groupsById = new Map();
|
|
const ignored = [];
|
|
const animations = Array.isArray(Animation.all) ? Animation.all.filter((animation) => animation) : [];
|
|
for (const animation of animations) {
|
|
const stage = parseAfwStageAnimationName(animation);
|
|
if (!stage) {
|
|
ignored.push(String(animation?.name || 'unnamed'));
|
|
continue;
|
|
}
|
|
if (!groupsById.has(stage.id)) groupsById.set(stage.id, []);
|
|
groupsById.get(stage.id).push(stage);
|
|
}
|
|
|
|
const groups = Array.from(groupsById, ([id, stages]) => {
|
|
stages.sort((a, b) => a.stageNumber - b.stageNumber || a.name.localeCompare(b.name));
|
|
const seenStages = new Set();
|
|
for (const stage of stages) {
|
|
if (seenStages.has(stage.stageNumber)) {
|
|
throw new Error(`Animation "${id}" contains more than one .p${stage.stageNumber} stage.`);
|
|
}
|
|
seenStages.add(stage.stageNumber);
|
|
}
|
|
return { id, stages };
|
|
});
|
|
groups.sort((a, b) => a.id.localeCompare(b.id));
|
|
return { groups, ignored };
|
|
}
|
|
|
|
function 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: stripBlockRequirementGuideBones(clone(compiled)) };
|
|
}
|
|
|
|
let clip = compiled.animations[stage.name];
|
|
if (!clip) {
|
|
const entries = Object.entries(compiled.animations);
|
|
if (entries.length !== 1) {
|
|
throw new Error(`Compiled animation "${stage.name}" did not contain a matching animation key.`);
|
|
}
|
|
clip = entries[0][1];
|
|
}
|
|
return {
|
|
formatVersion: compiled.format_version || '1.8.0',
|
|
clip: stripBlockRequirementGuideBones(clone(clip)),
|
|
};
|
|
}
|
|
|
|
function getActorIndicesFromAnimationClip(clip) {
|
|
const actors = new Set();
|
|
for (const boneName of Object.keys(clip?.bones || {})) {
|
|
const actorIndex = readActorIndexFromName(boneName);
|
|
if (actorIndex) actors.add(actorIndex);
|
|
}
|
|
return actors;
|
|
}
|
|
|
|
function validateConjoinedActorLabels(actorIndices) {
|
|
const labels = new Map();
|
|
const used = new Set();
|
|
for (const actorIndex of actorIndices) {
|
|
const label = String(getActorExportLabel(actorIndex) || '').trim();
|
|
if (!/^[a-z0-9._-]+$/.test(label)) {
|
|
throw new Error(`Actor ${actorIndex} export name "${label}" must use only lowercase letters, numbers, ., _ or -.`);
|
|
}
|
|
if (used.has(label)) {
|
|
throw new Error(`Actor export name "${label}" is assigned to more than one actor.`);
|
|
}
|
|
labels.set(actorIndex, label);
|
|
used.add(label);
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
function collectParticleOwnerIndices(node, owners, fieldName = '') {
|
|
if (node == null) return;
|
|
if (typeof node === 'string') {
|
|
if (fieldName === 'locator' || fieldName === 'bone') {
|
|
const actorIndex = readActorIndexFromName(node);
|
|
if (actorIndex) owners.add(actorIndex);
|
|
}
|
|
return;
|
|
}
|
|
if (typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
for (const value of node) collectParticleOwnerIndices(value, owners, fieldName);
|
|
return;
|
|
}
|
|
for (const [key, value] of Object.entries(node)) {
|
|
collectParticleOwnerIndices(value, owners, key);
|
|
}
|
|
}
|
|
|
|
function particleOwnerForEvent(event, stageName, cueTime) {
|
|
const owners = new Set();
|
|
collectParticleOwnerIndices(event, owners);
|
|
if (owners.size !== 1) {
|
|
const detail = owners.size === 0 ? 'no actor-prefixed locator' : 'locators from multiple actors';
|
|
throw new Error(`Particle cue ${stageName}@${cueTime} has ${detail}. Use one actorN_ locator per cue.`);
|
|
}
|
|
return owners.values().next().value;
|
|
}
|
|
|
|
function filterParticleEffectsForActor(clip, actorIndex, stageName) {
|
|
if (!clip?.particle_effects || typeof clip.particle_effects !== 'object') return;
|
|
const filtered = {};
|
|
for (const [cueTime, payload] of Object.entries(clip.particle_effects)) {
|
|
const events = Array.isArray(payload) ? payload : [payload];
|
|
const kept = events.filter((event) => particleOwnerForEvent(event, stageName, cueTime) === actorIndex);
|
|
if (!kept.length) continue;
|
|
filtered[cueTime] = Array.isArray(payload) ? kept : kept[0];
|
|
}
|
|
if (Object.keys(filtered).length) clip.particle_effects = filtered;
|
|
else delete clip.particle_effects;
|
|
}
|
|
|
|
function buildConjoinedAnimationOutput(group) {
|
|
const compiledStages = group.stages.map((stage) => ({ ...stage, ...compileConjoinedStage(stage) }));
|
|
const actorSet = new Set();
|
|
for (const stage of compiledStages) {
|
|
for (const actorIndex of getActorIndicesFromAnimationClip(stage.clip)) actorSet.add(actorIndex);
|
|
}
|
|
const actorIndices = Array.from(actorSet).sort((a, b) => a - b);
|
|
if (!actorIndices.length) {
|
|
throw new Error(`Animation "${group.id}" has no animated actorN_ bones.`);
|
|
}
|
|
|
|
const actorLabels = validateConjoinedActorLabels(actorIndices);
|
|
const soundOwner = actorIndices[0];
|
|
const output = {
|
|
format_version: compiledStages[0]?.formatVersion || '1.8.0',
|
|
animations: {},
|
|
};
|
|
|
|
for (const stage of compiledStages) {
|
|
for (const actorIndex of actorIndices) {
|
|
const clip = clone(stage.clip);
|
|
filterParticleEffectsForActor(clip, actorIndex, stage.name);
|
|
if (actorIndex !== soundOwner) delete clip.sound_effects;
|
|
|
|
const wrapped = { animations: { clip } };
|
|
stripPrefixFromAnimationFileForActor(wrapped, actorIndex);
|
|
output.animations[`p${stage.stageNumber}_${actorLabels.get(actorIndex)}`] = wrapped.animations.clip;
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: `${sanitizeFilePart(group.id)}.animation`,
|
|
content: stringifyInlineVec3(output, 2),
|
|
};
|
|
}
|
|
|
|
function exportSelectedConjoinedAnimationGroups(groups) {
|
|
try {
|
|
const outputs = groups.map(buildConjoinedAnimationOutput);
|
|
exportAnimationOutputs(
|
|
outputs,
|
|
`Exported ${outputs.length} animation file${outputs.length === 1 ? '' : 's'}`
|
|
);
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showMessageBox({
|
|
title: 'Export Animations',
|
|
message: `Export failed: ${e?.message || e}`,
|
|
buttons: ['OK'],
|
|
});
|
|
}
|
|
}
|
|
|
|
function showConjoinedAnimationExportDialog() {
|
|
let detected;
|
|
try {
|
|
detected = collectConjoinedAnimationGroups();
|
|
} catch (e) {
|
|
Blockbench.showMessageBox({
|
|
title: 'Export Animations',
|
|
message: e?.message || String(e),
|
|
buttons: ['OK'],
|
|
});
|
|
return;
|
|
}
|
|
if (!detected.groups.length) {
|
|
Blockbench.showMessageBox({
|
|
title: 'Export Animations',
|
|
message: 'No animations named <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();
|
|
}
|
|
);
|
|
}
|
|
|
|
const DEFINITION_GENERATOR_KEYS = new Set(['display_name', 'animators', 'system_tags', 'animation_tags',
|
|
'content_tags', 'required_union_tags', 'join_after', 'match_content_on_join', 'weight', 'attack_eligible',
|
|
'liquid_gain_multiplier', 'speed', 'water', 'position_anchor_actor', 'camera', 'manual_peak', 'tease', 'stuck',
|
|
'actors', 'stages', 'block_requirements']);
|
|
const ACTOR_GENERATOR_KEYS = new Set(['label', 'entity_types', 'entity_variant', 'actor_tags', 'actor_tags_any',
|
|
'activity', 'injector', 'receiver', 'injects_into', 'held_item']);
|
|
const STAGE_GENERATOR_KEYS = new Set(['stage', 'loop', 'cycle_seconds', 'allow_join', 'escapable', 'non_peak',
|
|
'speed', 'use_stage', 'stage_seconds', 'stage_duration_multiplier', 'cycle_midpoint_offset_seconds']);
|
|
|
|
function unmanagedAnimdefFields(source, keys) {
|
|
return Object.fromEntries(Object.entries(source || {}).filter(([key]) => !keys.has(key)));
|
|
}
|
|
|
|
function prepareAnimationDefinitionImport(source, analysis) {
|
|
const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
if (!isObject(source) || !Array.isArray(source.actors) || !source.actors.length
|
|
|| !Array.isArray(source.stages) || !source.stages.length) {
|
|
throw new Error('Expected an animation definition JSON with non-empty actors and stages arrays (not an animation asset).');
|
|
}
|
|
const definition = clone(source);
|
|
for (const key of ['animators', 'system_tags', 'animation_tags', 'content_tags', 'required_union_tags', 'join_after']) {
|
|
if (definition[key] !== undefined && (!Array.isArray(definition[key]) || definition[key].some((value) => typeof value !== 'string'))) {
|
|
throw new Error(`${key} must be an array of strings.`);
|
|
}
|
|
}
|
|
for (const key of ['camera', 'manual_peak', 'tease', 'stuck', 'block_requirements']) {
|
|
if (definition[key] !== undefined && !isObject(definition[key])) throw new Error(`${key} must be an object.`);
|
|
}
|
|
if (definition.system_tags === undefined && definition.animation_tags !== undefined) {
|
|
definition.system_tags = definition.animation_tags;
|
|
}
|
|
delete definition.animation_tags;
|
|
if (definition.actors.length !== analysis.actorIndices.length) {
|
|
throw new Error(`The definition has ${definition.actors.length} actors, but this animation has ${analysis.actorIndices.length}. Select an animation with the same actors.`);
|
|
}
|
|
const stageNumbers = new Set(analysis.stages.map((stage) => stage.stageNumber));
|
|
const seenStages = new Set();
|
|
for (const stage of definition.stages) {
|
|
if (!isObject(stage) || !Number.isInteger(stage.stage) || stage.stage < 1 || seenStages.has(stage.stage)) {
|
|
throw new Error('Imported stages must have unique stage numbers matching the target animation.');
|
|
}
|
|
const playback = stage.use_stage ?? stage.stage;
|
|
if (!Number.isInteger(playback) || !stageNumbers.has(playback)) {
|
|
throw new Error(`Stage p${stage.stage} requires animation clips for playback stage p${playback}.`);
|
|
}
|
|
seenStages.add(stage.stage);
|
|
}
|
|
if ([...stageNumbers].some((number) => !seenStages.has(number))) throw new Error('The definition must include every stage of the target animation.');
|
|
|
|
const roles = analysis.actorIndices.map((index) => analysis.actorLabels.get(index));
|
|
const labels = new Set();
|
|
for (const actor of definition.actors) {
|
|
if (!isObject(actor)) throw new Error('Every actor must be an object.');
|
|
if (actor.label !== undefined && (typeof actor.label !== 'string' || !actor.label.trim() || labels.has(actor.label))) {
|
|
throw new Error('Explicit actor labels must be non-empty, unique strings.');
|
|
}
|
|
if (actor.label) labels.add(actor.label);
|
|
for (const key of ['entity_types', 'actor_tags', 'actor_tags_any']) {
|
|
if (actor[key] !== undefined && (!Array.isArray(actor[key]) || actor[key].some((value) => typeof value !== 'string'))) {
|
|
throw new Error(`Actor ${key} must be an array of strings.`);
|
|
}
|
|
}
|
|
if (actor.held_item !== undefined && !isObject(actor.held_item)) throw new Error('Actor held_item must be an object.');
|
|
}
|
|
// Reserve exact matches first; unmatched or unlabeled actors use remaining roles in order.
|
|
const assigned = definition.actors.map((actor) => roles.includes(actor.label) ? actor.label : null);
|
|
const available = roles.filter((role) => !assigned.includes(role));
|
|
const labelMap = new Map();
|
|
definition.actors.forEach((actor, index) => {
|
|
assigned[index] ||= available.shift();
|
|
labelMap.set(actor.label || `actor${index + 1}`, assigned[index]);
|
|
});
|
|
definition.actors.forEach((actor, index) => {
|
|
actor.label = assigned[index];
|
|
if (actor.injects_into !== undefined) {
|
|
const targets = typeof actor.injects_into === 'string' ? [actor.injects_into] : actor.injects_into;
|
|
if (!Array.isArray(targets) || targets.some((target) => !labelMap.has(target))) {
|
|
throw new Error(`Actor ${actor.label} references an unknown injection target.`);
|
|
}
|
|
actor.injects_into = targets.map((target) => labelMap.get(target));
|
|
if (actor.injects_into.includes(actor.label)) throw new Error('An actor cannot inject into itself.');
|
|
}
|
|
});
|
|
definition.actors.sort((a, b) => roles.indexOf(a.label) - roles.indexOf(b.label));
|
|
if (definition.position_anchor_actor !== undefined) {
|
|
if (!labelMap.has(definition.position_anchor_actor)) throw new Error('The position anchor references an unknown actor.');
|
|
definition.position_anchor_actor = labelMap.get(definition.position_anchor_actor);
|
|
}
|
|
return definition;
|
|
}
|
|
|
|
function applyAnimationDefinitionImport(group, analysis, source) {
|
|
const definition = prepareAnimationDefinitionImport(source, analysis);
|
|
let entry = null;
|
|
if (definition.block_requirements) {
|
|
const req = definition.block_requirements;
|
|
if (!['wall', 'center_support'].includes(req.type)
|
|
|| (req.facing && !['front', 'back', 'left', 'right'].includes(req.facing))
|
|
|| (req.support && !['full_block', 'slab', 'half_height', 'surface'].includes(req.support))
|
|
|| (req.placement && !['directional_clearance', 'surface_footprint'].includes(req.placement))) {
|
|
throw new Error('The block requirement has an unsupported type, support, placement or facing.');
|
|
}
|
|
const form = buildBlockRequirementForm(group.id, req);
|
|
const values = Object.fromEntries(Object.entries(form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value]));
|
|
entry = readBlockRequirementForm(values);
|
|
buildBlockRequirementGuideBoxes(entry);
|
|
}
|
|
cancelBlockRequirementUpdate();
|
|
rebuildBlockRequirementGuides(group.id, entry, definition);
|
|
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id);
|
|
showAnimationDefinitionReviewDialog(group, analysis);
|
|
}
|
|
|
|
function importAnimationDefinition() {
|
|
if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500);
|
|
const projectId = Project.uuid;
|
|
Filesystem.importFile({ type: 'AFW Animation Definition', extensions: ['json'], multiple: false,
|
|
readtype: 'text', title: 'Import Animation Definition' }, (files) => {
|
|
if (!files?.length) return;
|
|
const report = (error) => Blockbench.showMessageBox({ title: 'Import Animation Definition', message: error.message, buttons: ['OK'] });
|
|
try {
|
|
if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.');
|
|
const source = JSON.parse(String(files[0].content || '').replace(/^\uFEFF/, ''));
|
|
const groups = collectConjoinedAnimationGroups().groups;
|
|
if (!groups.length) throw new Error('Import your animation assets first (named <animationId>.p<number>), then import their definition.');
|
|
const options = Object.fromEntries(groups.map((group) => [group.id, group.id]));
|
|
const fileId = String(files[0].name || files[0].path || '').split(/[\\/]/).pop().replace(/\.json$/i, '');
|
|
const selected = parseAfwStageAnimationName(getSelectedAnimation());
|
|
new Dialog({ id: 'animationdirector_import_animdef', title: 'Import Animation Definition', width: 520,
|
|
form: {
|
|
_info: { type: 'info', text: 'Choose the target animation. This replaces its saved definition and block guides. Actor labels are matched first; remaining actors are assigned in order. Keyframes and models are unchanged.' },
|
|
animation_id: { label: 'Animation', type: 'select', options,
|
|
value: options[fileId] ? fileId : options[selected?.id] ? selected.id : groups[0].id },
|
|
},
|
|
onConfirm(result) {
|
|
try {
|
|
if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.');
|
|
const group = collectConjoinedAnimationGroups().groups.find((candidate) => candidate.id === result.animation_id);
|
|
if (!group) throw new Error('The selected animation no longer exists.');
|
|
applyAnimationDefinitionImport(group, analyzeAnimationDefinitionGroup(group), source);
|
|
return true;
|
|
} catch (e) { report(e); return false; }
|
|
},
|
|
}).show();
|
|
} catch (e) { report(e); }
|
|
});
|
|
}
|
|
|
|
function showAnimationDefinitionReviewDialog(group, analysis) {
|
|
const savedMetadata = getProjectActorAnimdefMetadata();
|
|
const saved = getSavedAnimdef(group.id);
|
|
const savedActors = new Map((Array.isArray(saved.actors) ? saved.actors : []).map((actor) => [actor.label, actor]));
|
|
const savedStages = new Map((Array.isArray(saved.stages) ? saved.stages : []).map((stage) => [stage.stage, stage]));
|
|
const editableStages = new Map(analysis.stages.map((stage) => [stage.stageNumber, stage]));
|
|
const reusedStages = [...savedStages.values()].filter((stage) => !editableStages.has(stage.stage)
|
|
&& editableStages.has(stage.use_stage)).map((stage) => ({ ...editableStages.get(stage.use_stage),
|
|
stageNumber: stage.stage, loop: stage.loop ?? editableStages.get(stage.use_stage).loop }));
|
|
analysis = { ...analysis, stages: [...analysis.stages, ...reusedStages].sort((a, b) => a.stageNumber - b.stageNumber) };
|
|
const savedSystemTags = uniqueTrimmedStrings((Array.isArray(saved.system_tags) ? saved.system_tags : [])
|
|
.map((tag) => String(tag).toLowerCase()));
|
|
const savedTag = savedSystemTags[0] || '';
|
|
const selectedSystemTag = Object.hasOwn(SYSTEM_TAG_OPTIONS, savedTag) ? savedTag : 'other';
|
|
const form = {
|
|
_info: {
|
|
type: 'info',
|
|
text:
|
|
`Creating ${group.id}.json with ${analysis.actorIndices.length} actor(s) and ${analysis.stages.length} stage(s). ` +
|
|
'Save 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' };
|
|
const authoredRequirement = getAuthoredBlockRequirement(group.id);
|
|
const blockRequirement = authoredRequirement ? authoredRequirement.requirement : saved.block_requirements;
|
|
form._block_requirement = {
|
|
type: 'info',
|
|
text: blockRequirement
|
|
? `#### Block requirement\nIncluded automatically:\n\n\`\`\`json\n${JSON.stringify(blockRequirement, null, 2)}\n\`\`\`\nEdit using Tools > AnimationDirector Anims > Block Requirements.`
|
|
: '#### Block requirement\nNone. Use Tools > AnimationDirector Anims > Block Requirements to create one.',
|
|
};
|
|
form.base_speed = {
|
|
label: 'Base playback speed', type: 'text', value: saved.speed ?? '', placeholder: 'Default (1.0)',
|
|
description: 'Positive speed used unless an individual stage overrides it.',
|
|
};
|
|
form.water = {
|
|
label: 'Water placement', type: 'select', value: saved.water || 'none',
|
|
options: { none: 'No water requirement', surface: 'Water surface', underwater: 'Underwater' },
|
|
};
|
|
const anchorOptions = { '': 'Automatic anchor' };
|
|
for (const actorIndex of analysis.actorIndices) {
|
|
const label = analysis.actorLabels.get(actorIndex);
|
|
anchorOptions[label] = label;
|
|
}
|
|
form.position_anchor_actor = {
|
|
label: 'Position anchor actor', type: 'select', value: saved.position_anchor_actor || '', options: anchorOptions,
|
|
description: 'Prefer this actor as the animation placement anchor.',
|
|
};
|
|
form.camera_torso_tracking = {
|
|
label: 'Camera torso tracking', type: 'select', value: saved.camera?.torso_tracking || 'adaptive',
|
|
options: { adaptive: 'Adaptive (default)', continuous: 'Continuous' },
|
|
description: 'Continuous tracking follows the animated torso during each stage.',
|
|
};
|
|
form.camera_block_collision = {
|
|
label: 'Camera block collision', type: 'select', value: saved.camera?.block_requirement_collision || 'normal',
|
|
options: { normal: 'Normal (default)', ignore: 'Ignore required blocks' },
|
|
description: 'Ignore collision only with the blocks used by a block requirement.',
|
|
};
|
|
|
|
form._actors_separator = '_';
|
|
form._actors = { type: 'info', text: '### Actor constraints' };
|
|
analysis.actorIndices.forEach((actorIndex, actorPosition) => {
|
|
const label = analysis.actorLabels.get(actorIndex);
|
|
const savedActor = savedActors.get(label);
|
|
const metadata = savedActor ? cleanActorAnimdefMetadata({ ...savedActor,
|
|
injects_into: typeof savedActor.injects_into === 'string' ? [savedActor.injects_into] : savedActor.injects_into })
|
|
: actorAnimdefMetadataWithInference(actorIndex, savedMetadata[String(actorIndex)]);
|
|
addActorAnimdefFormFields(
|
|
form,
|
|
'stub_actor',
|
|
actorIndex,
|
|
label,
|
|
metadata,
|
|
actorPosition === 0 ? 'active' : 'passive',
|
|
undefined,
|
|
analysis.actorIndices.map((index) => analysis.actorLabels.get(index))
|
|
);
|
|
});
|
|
|
|
form._stages_separator = '_';
|
|
form._stages = { type: 'info', text: '### Stage metadata' };
|
|
|
|
analysis.stages.forEach((stage, index) => {
|
|
const base = `stub_stage_${stage.stageNumber}`;
|
|
const isLastStage = index === analysis.stages.length - 1;
|
|
const savedStage = savedStages.get(stage.stageNumber) || {};
|
|
const stageLoop = savedStage.loop ?? stage.loop;
|
|
const showAdvanced = (result) => !!result?.[`${base}_advanced`];
|
|
const showAdvancedLoop = (result) =>
|
|
showAdvanced(result) && (result?.[`${base}_loop`] ?? stageLoop);
|
|
form[`${base}_heading`] = {
|
|
type: 'info',
|
|
text: `#### Stage p${stage.stageNumber}\nDetected ${stage.loop ? 'looping' : 'non-looping'}, ${stage.cycleSeconds}s`,
|
|
};
|
|
form[`${base}_loop`] = {
|
|
label: 'Loop',
|
|
type: 'checkbox',
|
|
value: stageLoop,
|
|
};
|
|
form[`${base}_cycle_seconds`] = {
|
|
label: 'Cycle seconds',
|
|
type: 'number',
|
|
value: savedStage.cycle_seconds ?? stage.cycleSeconds,
|
|
min: 0.001,
|
|
step: 0.001,
|
|
description: 'Detected from the compiled GeckoLib animation_length.',
|
|
};
|
|
form[`${base}_allow_join`] = {
|
|
label: 'Allow joins',
|
|
type: 'checkbox',
|
|
value: savedStage.allow_join ?? !isLastStage,
|
|
};
|
|
form[`${base}_escapable`] = {
|
|
label: 'Escapable',
|
|
type: 'select',
|
|
options: { default: 'Use top-level default', true: 'Yes', false: 'No' },
|
|
value: savedStage.escapable == null
|
|
? (savedStages.has(stage.stageNumber) ? 'default' : isLastStage ? 'false' : 'default') : String(savedStage.escapable),
|
|
};
|
|
form[`${base}_peaked`] = {
|
|
label: 'Peaked stage',
|
|
type: 'checkbox',
|
|
value: savedStages.has(stage.stageNumber) ? savedStage.non_peak === true : isLastStage,
|
|
description: "Marks this as NoN's peaked stage.",
|
|
};
|
|
form[`${base}_advanced`] = {
|
|
label: 'Advanced stage settings',
|
|
type: 'checkbox',
|
|
style: 'toggle_switch',
|
|
value: !!(savedStage.speed || savedStage.use_stage || savedStage.stage_seconds
|
|
|| savedStage.cycle_midpoint_offset_seconds || savedStage.stage_duration_multiplier),
|
|
description: 'Show optional playback and duration controls.',
|
|
};
|
|
form[`${base}_speed`] = {
|
|
label: 'Playback speed',
|
|
type: 'text',
|
|
value: savedStage.speed ?? '',
|
|
placeholder: 'Default (1.0)',
|
|
condition: showAdvanced,
|
|
};
|
|
form[`${base}_use_stage`] = {
|
|
label: 'Play animation from stage',
|
|
type: 'select',
|
|
options: Object.fromEntries([
|
|
['', `Own stage (p${stage.stageNumber})`],
|
|
...analysis.stages.map((availableStage) => [
|
|
String(availableStage.stageNumber), `Stage p${availableStage.stageNumber}`,
|
|
]),
|
|
]),
|
|
value: savedStage.use_stage == null ? '' : String(savedStage.use_stage),
|
|
description: 'Optional stage number whose GeckoLib clip should be played instead.',
|
|
condition: showAdvanced,
|
|
};
|
|
form[`${base}_stage_seconds`] = {
|
|
label: 'Stage seconds',
|
|
type: 'text',
|
|
value: savedStage.stage_seconds ?? '',
|
|
placeholder: 'Use NoN settings',
|
|
condition: showAdvanced,
|
|
};
|
|
form[`${base}_midpoint_offset`] = {
|
|
label: 'Cycle midpoint offset seconds',
|
|
type: 'text',
|
|
value: savedStage.cycle_midpoint_offset_seconds ?? '',
|
|
placeholder: 'Default (0.0)',
|
|
condition: showAdvancedLoop,
|
|
};
|
|
form[`${base}_duration_multiplier`] = {
|
|
label: 'Stage duration multiplier',
|
|
type: 'text',
|
|
value: savedStage.stage_duration_multiplier ?? '',
|
|
placeholder: 'Default (1.0)',
|
|
condition: showAdvancedLoop,
|
|
};
|
|
});
|
|
|
|
const 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({ ...unmanagedAnimdefFields(savedActors.get(label), ACTOR_GENERATOR_KEYS),
|
|
...buildActorConstraint(label, metadata) });
|
|
}
|
|
|
|
const stages = analysis.stages.map((stage, index) => ({
|
|
...unmanagedAnimdefFields(savedStages.get(stage.stageNumber), STAGE_GENERATOR_KEYS),
|
|
...readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1),
|
|
}));
|
|
const actorsByLabel = new Map(actors.map((actor) => [actor.label, actor]));
|
|
const explicitReceivers = actors.some((actor) => actor.receiver);
|
|
const inferredReceivers = actors.some((actor) => !actor.injector);
|
|
for (const actor of actors) {
|
|
const targets = typeof actor.injects_into === 'string'
|
|
? [actor.injects_into] : actor.injects_into || [];
|
|
for (const target of targets) {
|
|
const receiver = actorsByLabel.get(target);
|
|
if (!receiver || target === actor.label) {
|
|
throw new Error(`Actor "${actor.label}" has invalid injection target "${target}".`);
|
|
}
|
|
const canReceive = explicitReceivers ? receiver.receiver
|
|
: inferredReceivers ? !receiver.injector
|
|
: receiver.entity_types?.includes('minecraft:player');
|
|
if (!canReceive) throw new Error(`Actor "${target}" is not a receiver for "${actor.label}".`);
|
|
}
|
|
}
|
|
const stageNumbers = new Set(stages.map((stage) => stage.stage));
|
|
for (const stage of stages) {
|
|
if (stage.use_stage && !stageNumbers.has(stage.use_stage)) {
|
|
throw new Error(`Stage p${stage.stage} references missing playback stage p${stage.use_stage}.`);
|
|
}
|
|
}
|
|
const displayName = String(result?.display_name || '').trim();
|
|
const animators = normalizeAnimatorNames(String(result?.animators || '').split(','));
|
|
const definition = unmanagedAnimdefFields(saved, DEFINITION_GENERATOR_KEYS);
|
|
const authored = getAuthoredBlockRequirement(group.id);
|
|
const requirement = authored ? authored.requirement : saved.block_requirements;
|
|
if (requirement) definition.block_requirements = clone(requirement);
|
|
if (displayName) definition.display_name = displayName;
|
|
if (animators.length) definition.animators = animators;
|
|
const selectedTag = String(result?.system_tags || '');
|
|
const customTag = String(result?.other_system_tags || '').trim().toLowerCase();
|
|
if (selectedTag === 'other' && (!customTag || customTag.includes(','))) {
|
|
throw new Error('Enter one custom system tag or choose a different behavior.');
|
|
}
|
|
const systemTags = selectedTag === 'other' ? [customTag] : selectedTag ? [selectedTag] : [];
|
|
const contentTags = uniqueTrimmedStrings(parseCommaSeparatedStrings(result?.content_tags)
|
|
.map((tag) => tag.toLowerCase()));
|
|
const unionTags = parseCommaSeparatedStrings(result?.required_union_tags);
|
|
const joinAfter = parseCommaSeparatedStrings(result?.join_after);
|
|
for (const id of joinAfter) {
|
|
if (!RESOURCE_ID_PATTERN.test(id)) {
|
|
throw new Error(`Invalid join_after animation ID "${id}". Use namespace:path.`);
|
|
}
|
|
}
|
|
if (systemTags.length) definition.system_tags = systemTags;
|
|
if (contentTags.length) definition.content_tags = contentTags;
|
|
if (unionTags.length) definition.required_union_tags = unionTags;
|
|
if (joinAfter.length) definition.join_after = joinAfter;
|
|
if (result?.match_content_on_join) {
|
|
if (!contentTags.length) throw new Error('Match content on join requires at least one content tag.');
|
|
definition.match_content_on_join = true;
|
|
}
|
|
const weight = parseOptionalNumber(result?.weight, 'Selection weight', (value) => value > 0);
|
|
if (weight !== null && weight !== 1) definition.weight = weight;
|
|
if (result?.attack_eligible === false) definition.attack_eligible = false;
|
|
const baseSpeed = parseOptionalNumber(result?.base_speed, 'Base playback speed', (value) => value > 0);
|
|
if (baseSpeed !== null && baseSpeed !== 1) definition.speed = baseSpeed;
|
|
const water = String(result?.water || 'none');
|
|
if (water !== 'none') definition.water = water;
|
|
const anchor = String(result?.position_anchor_actor || '');
|
|
if (anchor) definition.position_anchor_actor = anchor;
|
|
const camera = unmanagedAnimdefFields(saved.camera, new Set(['torso_tracking', 'block_requirement_collision']));
|
|
if (result?.camera_torso_tracking === 'continuous') camera.torso_tracking = 'continuous';
|
|
if (result?.camera_block_collision === 'ignore') camera.block_requirement_collision = 'ignore';
|
|
if (Object.keys(camera).length) definition.camera = camera;
|
|
const liquidGain = parseOptionalNumber(result?.liquid_gain_multiplier,
|
|
'Liquid gain multiplier', (value) => value >= 0);
|
|
if (liquidGain !== null && liquidGain !== 1) definition.liquid_gain_multiplier = liquidGain;
|
|
if (systemTags.includes('manualpeak')) {
|
|
const priority = parseOptionalNumber(result?.manual_peak_priority,
|
|
'Manual peak priority', Number.isInteger);
|
|
const manualPeak = unmanagedAnimdefFields(saved.manual_peak, new Set(['priority']));
|
|
if (priority !== null && priority !== 0) manualPeak.priority = priority;
|
|
if (Object.keys(manualPeak).length) definition.manual_peak = manualPeak;
|
|
}
|
|
if (systemTags.includes('tease')) {
|
|
const multiplier = parseOptionalNumber(
|
|
result?.stub_tease_multiplier,
|
|
'Nearby energy multiplier',
|
|
(value) => value >= 1 && value <= 20
|
|
);
|
|
if (multiplier === null) throw new Error('Nearby energy multiplier is required.');
|
|
definition.tease = { ...saved.tease, near_animation_energy_multiplier: multiplier };
|
|
}
|
|
if (systemTags.includes('stuck')) {
|
|
if (!contentTags.length) {
|
|
throw new Error('A stuck animation needs at least one matching content tag.');
|
|
}
|
|
if (!stages.some((stage) => stage.escapable ?? true)) {
|
|
throw new Error('A stuck animation needs at least one escapable stage.');
|
|
}
|
|
const initial = parseOptionalNumber(
|
|
result?.stub_stuck_initial_difficulty,
|
|
'Initial escape difficulty',
|
|
(value) => value >= 0.1 && value <= 10
|
|
);
|
|
const finalDifficulty = parseOptionalNumber(
|
|
result?.stub_stuck_final_difficulty,
|
|
'Final escape difficulty',
|
|
(value) => value >= 0.1 && value <= 10
|
|
);
|
|
const delay = parseOptionalNumber(
|
|
result?.stub_stuck_ease_delay,
|
|
'Ease delay seconds',
|
|
(value) => Number.isInteger(value) && value >= 0 && value <= 600
|
|
);
|
|
const duration = parseOptionalNumber(
|
|
result?.stub_stuck_ease_duration,
|
|
'Ease duration seconds',
|
|
(value) => Number.isInteger(value) && value >= 0 && value <= 600
|
|
);
|
|
const joined = parseOptionalNumber(
|
|
result?.stub_stuck_joined_difficulty,
|
|
'Joined escape difficulty',
|
|
(value) => value >= 0.1 && value <= 10
|
|
);
|
|
if ([initial, finalDifficulty, delay, duration, joined].some((value) => value === null)) {
|
|
throw new Error('Every stuck behavior value is required.');
|
|
}
|
|
if (finalDifficulty > initial) {
|
|
throw new Error('Final escape difficulty cannot be harder than the initial difficulty.');
|
|
}
|
|
definition.stuck = {
|
|
...saved.stuck,
|
|
initial_escape_difficulty_multiplier: initial,
|
|
final_escape_difficulty_multiplier: finalDifficulty,
|
|
ease_delay_seconds: delay,
|
|
ease_duration_seconds: duration,
|
|
joined_escape_difficulty_multiplier: joined,
|
|
damage_behavior: String(result?.stub_stuck_damage_behavior || 'stop_on_damage'),
|
|
};
|
|
}
|
|
definition.actors = actors;
|
|
definition.stages = stages;
|
|
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 hasActorPrefix(name) {
|
|
return readActorIndexFromName(name) !== null;
|
|
}
|
|
|
|
function addPrefixIfMissing(name, actorIndex) {
|
|
return hasActorPrefix(name) ? name : `actor${actorIndex}_${name}`;
|
|
}
|
|
|
|
function prefixAnimationObject(animObj, actorIndex) {
|
|
const a = clone(animObj || {});
|
|
|
|
const bones = a?.bones && typeof a.bones === 'object' ? a.bones : null;
|
|
if (!bones) throw new Error('Animation has no "bones" map.');
|
|
|
|
const newBones = {};
|
|
for (const boneName of Object.keys(bones)) {
|
|
const newName = addPrefixIfMissing(boneName, actorIndex);
|
|
if (newBones[newName]) throw new Error(`Bone collision inside one file: "${newName}"`);
|
|
newBones[newName] = bones[boneName];
|
|
}
|
|
a.bones = newBones;
|
|
|
|
const fixStrings = (node) => {
|
|
if (!node) return;
|
|
if (Array.isArray(node)) {
|
|
for (const v of node) fixStrings(v);
|
|
return;
|
|
}
|
|
if (typeof node !== 'object') return;
|
|
|
|
for (const k of Object.keys(node)) {
|
|
const v = node[k];
|
|
if (typeof v === 'string' && (k === 'bone' || k === 'locator')) {
|
|
node[k] = addPrefixIfMissing(v, actorIndex);
|
|
} else {
|
|
fixStrings(v);
|
|
}
|
|
}
|
|
};
|
|
fixStrings(a);
|
|
|
|
return a;
|
|
}
|
|
|
|
function parseConjoinedStageActorKey(key) {
|
|
const match = /^p([1-9]\d*)_(.+)$/.exec(String(key || '').trim());
|
|
if (!match || !/^[a-z0-9._-]+$/.test(match[2])) return null;
|
|
return {
|
|
stageNumber: Number(match[1]),
|
|
actorLabel: match[2],
|
|
};
|
|
}
|
|
|
|
function conjoinedAnimationIdFromFile(file) {
|
|
const rawName = String(file?.name || file?.path || '').trim();
|
|
const fileName = rawName.split(/[\\/]/).pop() || '';
|
|
const match = /^([a-z0-9._-]+)\.animation\.json$/.exec(fileName);
|
|
if (!match) {
|
|
throw new Error(
|
|
`Conjoined animation filename "${fileName || '(unknown)'}" must use <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 resolveDefinitionImportStages(parsed, definition) {
|
|
if (!Array.isArray(definition.stages) || !definition.stages.length) {
|
|
throw new Error('The definition must contain at least one stage.');
|
|
}
|
|
const numbers = new Set();
|
|
for (const stage of definition.stages) {
|
|
if (!stage || !Number.isInteger(stage.stage) || stage.stage < 1 || numbers.has(stage.stage)) {
|
|
throw new Error('Definition stages must have unique positive stage numbers.');
|
|
}
|
|
numbers.add(stage.stage);
|
|
}
|
|
const clips = new Map(parsed.stages.map((stage) => [stage.stageNumber, stage]));
|
|
for (const stage of definition.stages) {
|
|
const playback = stage.use_stage ?? stage.stage;
|
|
if (!Number.isInteger(playback) || !numbers.has(playback)) {
|
|
throw new Error(`Stage p${stage.stage} references missing playback stage p${playback}.`);
|
|
}
|
|
// NoN references the target's clips directly, not its own use_stage setting.
|
|
const source = clips.get(playback);
|
|
if (!source) throw new Error(`Stage p${stage.stage} requires animation clips for playback stage p${playback}.`);
|
|
}
|
|
// Reused stages remain definition metadata, not duplicate editable animations.
|
|
return definition.stages.filter((stage) => stage.use_stage == null || stage.use_stage === stage.stage)
|
|
.map((stage) => clips.get(stage.stage)).sort((a, b) => a.stageNumber - b.stageNumber);
|
|
}
|
|
|
|
function buildConjoinedImportFile(parsed, outputAnimationId, actorMapping) {
|
|
const animations = {};
|
|
const stageNames = [];
|
|
for (const stage of parsed.stages) {
|
|
const stageName = `${outputAnimationId}.p${stage.stageNumber}`;
|
|
stageNames.push(stageName);
|
|
animations[stageName] = mergeConjoinedStage(stage, actorMapping, stageName);
|
|
}
|
|
return {
|
|
fileObj: {
|
|
format_version: parsed.formatVersion,
|
|
animations,
|
|
},
|
|
stageNames,
|
|
};
|
|
}
|
|
|
|
function defaultActorOptionForLabel(actorLabel, actorOptions) {
|
|
for (const key of Object.keys(actorOptions)) {
|
|
if (getActorExportLabel(Number(key)) === actorLabel) return key;
|
|
}
|
|
const actorMatch = /^actor([1-9]\d*)$/.exec(actorLabel);
|
|
if (actorMatch && Object.prototype.hasOwnProperty.call(actorOptions, actorMatch[1])) return actorMatch[1];
|
|
return Object.keys(actorOptions)[0];
|
|
}
|
|
|
|
function importConjoinedAnimation() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
if (typeof Animator?.loadFile !== 'function') {
|
|
Blockbench.showMessageBox({
|
|
title: 'Import Animation',
|
|
message: 'Animation importer not available in this Blockbench setup (missing Animator.loadFile).',
|
|
buttons: ['OK'],
|
|
});
|
|
return;
|
|
}
|
|
|
|
Filesystem.importFile(
|
|
{
|
|
type: 'AnimationDirector Conjoined Animation JSON',
|
|
extensions: ['json'],
|
|
multiple: false,
|
|
readtype: 'text',
|
|
resource_id: 'animation',
|
|
title: 'Select a conjoined animation file',
|
|
},
|
|
(files) => {
|
|
if (!files?.length) return;
|
|
|
|
try {
|
|
const parsed = parseConjoinedAnimationFile(files[0]);
|
|
const actorOptions = buildActorSelectOptions();
|
|
const actorOptionKeys = Object.keys(actorOptions);
|
|
if (!actorOptionKeys.length) {
|
|
Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
|
|
return;
|
|
}
|
|
|
|
const form = {
|
|
animation_id: {
|
|
label: 'Animation ID',
|
|
type: 'text',
|
|
value: parsed.animationId,
|
|
placeholder: 'wolfmplayer',
|
|
},
|
|
_info: {
|
|
type: 'info',
|
|
text: 'Map each actor stored in the file to an actor in this project. All stages will be imported together.',
|
|
},
|
|
};
|
|
for (let i = 0; i < parsed.actorLabels.length; i++) {
|
|
const actorLabel = parsed.actorLabels[i];
|
|
form[`actor_${i}`] = {
|
|
label: actorLabel,
|
|
type: 'select',
|
|
options: actorOptions,
|
|
value: defaultActorOptionForLabel(actorLabel, actorOptions),
|
|
};
|
|
}
|
|
|
|
const dialog = new Dialog({
|
|
id: 'import_conjoined_actor_animation',
|
|
title: 'Import Animation',
|
|
width: 620,
|
|
form,
|
|
buttons: ['Cancel', 'Import'],
|
|
cancelIndex: 0,
|
|
confirmIndex: 1,
|
|
onConfirm: (result) => {
|
|
try {
|
|
const outputAnimationId = String(result?.animation_id || '').trim();
|
|
if (!/^[a-z0-9._-]+$/.test(outputAnimationId)) {
|
|
throw new Error('Animation ID must use only lowercase letters, numbers, ., _ or -.');
|
|
}
|
|
|
|
const actorMapping = new Map();
|
|
const usedActorIndices = new Set();
|
|
for (let i = 0; i < parsed.actorLabels.length; i++) {
|
|
const actorLabel = parsed.actorLabels[i];
|
|
const actorIndex = Number(result?.[`actor_${i}`]);
|
|
if (!Number.isFinite(actorIndex) || actorIndex <= 0) {
|
|
throw new Error(`Invalid project actor selected for "${actorLabel}".`);
|
|
}
|
|
if (usedActorIndices.has(actorIndex)) {
|
|
throw new Error('Each file actor must map to a different project actor.');
|
|
}
|
|
actorMapping.set(actorLabel, Math.floor(actorIndex));
|
|
usedActorIndices.add(Math.floor(actorIndex));
|
|
}
|
|
|
|
const imported = buildConjoinedImportFile(parsed, outputAnimationId, actorMapping);
|
|
const doImport = () => {
|
|
const fakeFile = {
|
|
name: `${outputAnimationId}.animation.json`,
|
|
path: '',
|
|
content: JSON.stringify(imported.fileObj, null, 2),
|
|
};
|
|
Animator.loadFile(fakeFile, imported.stageNames);
|
|
Blockbench.showQuickMessage(
|
|
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
|
|
2500
|
|
);
|
|
};
|
|
|
|
const existing = imported.stageNames.filter(animationNameExists);
|
|
if (existing.length) {
|
|
Blockbench.showMessageBox(
|
|
{
|
|
title: 'Import Animation',
|
|
message: `The following animations already exist:\n\n${existing.map((name) => `- ${name}`).join('\n')}\n\nOverwrite them?`,
|
|
buttons: ['Cancel', 'Overwrite'],
|
|
},
|
|
(button) => {
|
|
if (button !== 1 && button !== 'Overwrite') return;
|
|
for (const name of existing) removeAnimationByName(name);
|
|
doImport();
|
|
}
|
|
);
|
|
} else {
|
|
doImport();
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showMessageBox({
|
|
title: 'Import Animation',
|
|
message: `Import failed: ${e?.message || e}`,
|
|
buttons: ['OK'],
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
dialog.show();
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showMessageBox({
|
|
title: 'Import Animation',
|
|
message: `Import failed: ${e?.message || e}`,
|
|
buttons: ['OK'],
|
|
});
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function animationNameExists(name) {
|
|
const list = Animation.all;
|
|
if (!Array.isArray(list)) return false;
|
|
for (const a of list) if (a?.name === name) return true;
|
|
return false;
|
|
}
|
|
|
|
function removeAnimationByName(name) {
|
|
const list = Animation.all;
|
|
if (!Array.isArray(list)) return;
|
|
|
|
const a = list.find((x) => x?.name === name);
|
|
if (!a) return;
|
|
|
|
if (typeof a.remove === 'function') a.remove();
|
|
else if (typeof a.delete === 'function') a.delete();
|
|
}
|
|
|
|
// Automatic import reads only explicitly registered, extracted packs. No network access.
|
|
const PACK_FOLDERS_KEY = 'animationdirector_pack_folders';
|
|
const IMPORT_METADATA = [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, BONE_TEXTURE_OVERRIDES_PROPERTY,
|
|
ACTOR_ANIMDEF_METADATA_PROPERTY, ANIMDEF_ANIMATORS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, BLOCK_REQUIREMENTS_PROPERTY, PROP_PREVIEWS_PROPERTY];
|
|
|
|
// Item previews are private Three.js attachments, never actor geometry or animation tracks.
|
|
function propResourcePath(id, kind, extension) {
|
|
const value = String(id || '');
|
|
const resource = value.includes(':') ? value : `minecraft:${value}`;
|
|
if (!/^[a-z0-9_.-]+:[a-z0-9_./-]+$/.test(resource) || resource.split(':')[1].split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
throw new Error(`Invalid item resource ID: ${value}`);
|
|
}
|
|
const [namespace, name] = resource.split(':');
|
|
return `assets/${namespace}/${kind}/${name}${extension}`;
|
|
}
|
|
|
|
function indexPropZip(bytes) {
|
|
const data = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes), files = new Map();
|
|
if (data.length > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.');
|
|
let end = -1;
|
|
for (let offset = data.length - 22; offset >= Math.max(0, data.length - 65557); offset--) {
|
|
if (data.readUInt32LE(offset) === 0x06054b50 && offset + 22 + data.readUInt16LE(offset + 20) === data.length) { end = offset; break; }
|
|
}
|
|
if (end < 0) throw new Error('Not a supported ZIP/JAR archive.');
|
|
if (data.readUInt16LE(end + 4) || data.readUInt16LE(end + 6)) throw new Error('Split archives are unsupported.');
|
|
const count = data.readUInt16LE(end + 10), start = data.readUInt32LE(end + 16);
|
|
const limit = start + data.readUInt32LE(end + 12);
|
|
if (count === 65535 || start === 0xffffffff || limit > end) throw new Error('ZIP64 or invalid archive directory.');
|
|
let offset = start;
|
|
for (let index = 0; index < count; index++) {
|
|
if (offset + 46 > limit || data.readUInt32LE(offset) !== 0x02014b50) throw new Error('Invalid archive directory.');
|
|
const nameLength = data.readUInt16LE(offset + 28), extra = data.readUInt16LE(offset + 30), comment = data.readUInt16LE(offset + 32);
|
|
if (offset + 46 + nameLength + extra + comment > limit) throw new Error('Truncated archive directory.');
|
|
const name = data.toString('utf8', offset + 46, offset + 46 + nameLength);
|
|
if (name.startsWith('assets/') && !name.endsWith('/') && !name.includes('\\') && !name.split('/').includes('..')) {
|
|
files.set(name, { flags: data.readUInt16LE(offset + 8), method: data.readUInt16LE(offset + 10),
|
|
size: data.readUInt32LE(offset + 24), compressed: data.readUInt32LE(offset + 20), local: data.readUInt32LE(offset + 42) });
|
|
}
|
|
offset += 46 + nameLength + extra + comment;
|
|
}
|
|
return { files, read(name) {
|
|
const file = files.get(name);
|
|
if (!file) return null;
|
|
if (file.flags & 1 || file.size > 16 * 1024 * 1024) throw new Error('Encrypted or oversized asset entry.');
|
|
const local = file.local;
|
|
if (local + 30 > data.length || data.readUInt32LE(local) !== 0x04034b50) throw new Error('Invalid asset entry.');
|
|
const start = local + 30 + data.readUInt16LE(local + 26) + data.readUInt16LE(local + 28);
|
|
if (start + file.compressed > data.length) throw new Error('Truncated asset entry.');
|
|
const content = data.subarray(start, start + file.compressed);
|
|
let decoded;
|
|
if (file.method === 0) decoded = content;
|
|
else if (file.method === 8) {
|
|
const load = typeof requireNativeModule === 'function' ? requireNativeModule : require;
|
|
const zlib = load('zlib');
|
|
if (!zlib?.inflateRawSync) throw new Error('ZIP decompression is unavailable. Allow zlib access or use extracted assets.');
|
|
decoded = zlib.inflateRawSync(content, { maxOutputLength: 16 * 1024 * 1024 });
|
|
} else throw new Error('Unsupported asset compression method.');
|
|
if (decoded.length !== file.size) throw new Error('Asset entry size mismatch.');
|
|
return decoded;
|
|
} };
|
|
}
|
|
|
|
function openPropSource(file, io = desktopFiles()) {
|
|
const resolved = importFilePath(file, io), stats = io.fs.statSync(resolved);
|
|
const signature = `${resolved}:${stats.mtimeMs}:${stats.size}`;
|
|
if (propSources.has(signature)) return propSources.get(signature);
|
|
let source;
|
|
if (stats.isDirectory()) {
|
|
const root = io.path.basename(resolved) === 'assets' ? io.path.dirname(resolved) : resolved;
|
|
source = { read(name) {
|
|
const target = io.path.join(root, ...name.split('/'));
|
|
if (!io.fs.existsSync(target)) return null;
|
|
if (!insidePack(root, importFilePath(target, io), io)) throw new Error('Asset path escapes the selected folder.');
|
|
if (io.fs.statSync(target).size > 16 * 1024 * 1024) throw new Error('Asset exceeds the 16 MiB limit.');
|
|
return Buffer.from(io.fs.readFileSync(target));
|
|
}, itemIds() {
|
|
const ids = [], assets = io.path.join(root,'assets');
|
|
if (!io.fs.existsSync(assets)) return ids;
|
|
for (const namespace of io.fs.readdirSync(assets).slice(0,256)) {
|
|
const folder = io.path.join(assets,namespace,'items');
|
|
if (!io.fs.existsSync(folder)) continue;
|
|
const scan = (folder,prefix = '',depth = 0) => {
|
|
if (depth > 8 || ids.length >= 10000) return;
|
|
insidePack(root,folder,io);
|
|
for (const entry of io.fs.readdirSync(folder,{withFileTypes:true})) {
|
|
if (entry.isSymbolicLink()) continue;
|
|
if (entry.isDirectory()) scan(io.path.join(folder,entry.name),`${prefix}${entry.name}/`,depth+1);
|
|
else if (entry.name.endsWith('.json')) ids.push(`${namespace}:${prefix}${entry.name.slice(0,-5)}`);
|
|
if (ids.length >= 10000) break;
|
|
}
|
|
};
|
|
scan(folder);
|
|
}
|
|
return ids.sort();
|
|
} };
|
|
} else {
|
|
if (stats.size > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.');
|
|
source = indexPropZip(io.fs.readFileSync(resolved));
|
|
}
|
|
source.path = resolved;
|
|
// Keep only the most recently used archive rather than retaining several entire client JARs.
|
|
propSources.clear(); propSources.set(signature, source);
|
|
return source;
|
|
}
|
|
|
|
function discoverPropSources(io = desktopFiles()) {
|
|
const results = [];
|
|
const add = (file, label) => { if (io.fs.existsSync(file) && !results.some((entry) => entry.file === file)) results.push({ file, label }); };
|
|
if (typeof SystemInfo !== 'undefined' && SystemInfo.user_data_directory) {
|
|
const cache = io.path.join(SystemInfo.user_data_directory, 'minecraft_assets_cache');
|
|
if (io.fs.existsSync(cache)) for (const name of io.fs.readdirSync(cache).slice(0, 500)) {
|
|
if (/\.jar$/i.test(name)) add(io.path.join(cache, name), `Asset Browser: ${name.replace(/\.jar$/i, '')}`);
|
|
}
|
|
}
|
|
let minecraft = typeof settings !== 'undefined' ? settings.ewan_minecraft_directory?.value : null;
|
|
if (!minecraft && typeof SystemInfo !== 'undefined') {
|
|
if (SystemInfo.platform === 'win32' && SystemInfo.appdata_directory) minecraft = io.path.join(SystemInfo.appdata_directory, '.minecraft');
|
|
else if (SystemInfo.home_directory) minecraft = io.path.join(SystemInfo.home_directory,
|
|
...(SystemInfo.platform === 'darwin' ? ['Library', 'Application Support', 'minecraft'] : ['.minecraft']));
|
|
}
|
|
if (minecraft) {
|
|
const versions = io.path.join(minecraft, 'versions');
|
|
if (io.fs.existsSync(versions)) for (const name of io.fs.readdirSync(versions).slice(0, 500)) {
|
|
add(io.path.join(versions, name, `${name}.jar`), `Minecraft: ${name}`);
|
|
}
|
|
}
|
|
const last = typeof localStorage !== 'undefined' ? localStorage.getItem('animationdirector_prop_source') : null;
|
|
if (last) add(last, `Selected: ${io.path.basename(last)}`);
|
|
return results;
|
|
}
|
|
|
|
function propItemOptions(items, search) {
|
|
const query = String(search || '').trim().toLowerCase();
|
|
return { '': 'Select a matching item...', ...Object.fromEntries(items
|
|
.filter((id) => id.toLowerCase().includes(query)).map((id) => [id, id])) };
|
|
}
|
|
|
|
function propJson(source, path) {
|
|
const content = source.read(path);
|
|
return content ? JSON.parse(content.toString('utf8').replace(/^\uFEFF/, '')) : null;
|
|
}
|
|
|
|
function resolvePropModel(source, id, ancestors = new Set()) {
|
|
const path = propResourcePath(id, 'models', '.json');
|
|
if (ancestors.has(path) || ancestors.size >= 32) throw new Error(`Cyclic or excessive item model inheritance: ${id}`);
|
|
const json = propJson(source, path);
|
|
if (!json) throw new Error(`Missing item model: ${id}`);
|
|
ancestors = new Set(ancestors); ancestors.add(path);
|
|
let parent = {};
|
|
if (json.parent && !/^(minecraft:)?builtin\//.test(json.parent)) parent = resolvePropModel(source, json.parent, ancestors);
|
|
const result = { ...parent, ...json, textures: { ...parent.textures, ...json.textures }, display: { ...parent.display, ...json.display } };
|
|
result.generated = json.parent && /^(minecraft:)?builtin\/generated$/.test(json.parent) || parent.generated || false;
|
|
return result;
|
|
}
|
|
|
|
function propItemModels(node, warnings, depth = 0) {
|
|
if (!node || depth > 20) throw new Error('Missing or excessive item definition tree.');
|
|
const type = String(node.type || '').replace(/^minecraft:/, '');
|
|
if (type === 'model' && typeof node.model === 'string') {
|
|
if (node.tints?.length) warnings.push('This item uses component-dependent tinting; the preview uses its default tint.');
|
|
return [{ id: node.model, tints: node.tints || [] }];
|
|
}
|
|
if (type === 'composite') return (node.models || []).flatMap((child) => propItemModels(child, warnings, depth + 1));
|
|
if (['condition', 'select', 'range_dispatch'].includes(type)) {
|
|
warnings.push('Component-dependent item appearance: previewing its fallback/default branch.');
|
|
const child = type === 'condition' ? node.on_false : node.fallback;
|
|
if (!child) throw new Error('This conditional item has no default appearance. Use a manual texture preview.');
|
|
return propItemModels(child, warnings, depth + 1);
|
|
}
|
|
throw new Error(`Item renderer '${type || 'unknown'}' is not supported. Use a manual texture preview.`);
|
|
}
|
|
|
|
function propTextureId(value, textures) {
|
|
const seen = new Set();
|
|
while (typeof value === 'string' && value.startsWith('#')) {
|
|
if (seen.has(value)) throw new Error(`Cyclic item texture reference: ${value}`);
|
|
seen.add(value); value = textures[value.slice(1)];
|
|
}
|
|
if (typeof value !== 'string') throw new Error('Missing item texture reference.');
|
|
return value;
|
|
}
|
|
|
|
function propDisplayTransform(model, slot) {
|
|
const context = slot === 'floor' ? 'ground' : `thirdperson_${slot}hand`;
|
|
const explicit = model.display?.[context];
|
|
const data = explicit || (slot === 'left' ? model.display?.thirdperson_righthand : null) || {};
|
|
const rotation = v3(data.rotation), translation = v3(data.translation), scale = v3(data.scale, 1);
|
|
// Minecraft mirrors the selected display transform for left-hand rendering.
|
|
if (slot === 'left') {
|
|
translation[0] = -translation[0] || 0;
|
|
rotation[1] = -rotation[1] || 0;
|
|
rotation[2] = -rotation[2] || 0;
|
|
}
|
|
return { rotation, translation, scale };
|
|
}
|
|
|
|
function manualPropModel(preset) {
|
|
const generated = { generated: true, display: {
|
|
ground: { translation: [0, 2, 0], scale: [0.5, 0.5, 0.5] },
|
|
thirdperson_righthand: { translation: [0, 3, 1], scale: [0.55, 0.55, 0.55] },
|
|
} };
|
|
if (preset === 'handheld') generated.display = { ...generated.display,
|
|
thirdperson_righthand: { rotation: [0, -90, 55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] },
|
|
thirdperson_lefthand: { rotation: [0, 90, -55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] } };
|
|
return generated;
|
|
}
|
|
|
|
function loadPropAsset(source, item, slot) {
|
|
propResourcePath(item, 'items', '.json');
|
|
const warnings = [], definition = propJson(source, propResourcePath(item, 'items', '.json'));
|
|
const refs = definition ? propItemModels(definition.model, warnings) : [{ id: `${item.includes(':') ? item.split(':')[0] : 'minecraft'}:item/${item.split(':').pop()}`, tints: [] }];
|
|
const parts = [];
|
|
for (const ref of refs) {
|
|
const model = resolvePropModel(source, ref.id), transform = propDisplayTransform(model, slot);
|
|
const textures = {}, frames = {};
|
|
const addTexture = (value) => {
|
|
const id = propTextureId(value, model.textures);
|
|
const png = source.read(propResourcePath(id, 'textures', '.png'));
|
|
if (!png || png.length > 4 * 1024 * 1024) throw new Error(`Missing or oversized item texture: ${id}`);
|
|
textures[id] ||= `data:image/png;base64,${png.toString('base64')}`;
|
|
const metadata = propJson(source, propResourcePath(id, 'textures', '.png.mcmeta'));
|
|
if (metadata?.animation) {
|
|
warnings.push('Animated texture: the preview uses its first frame.');
|
|
frames[id] = metadata.animation;
|
|
}
|
|
return id;
|
|
};
|
|
if (model.generated) {
|
|
for (let layer = 0; layer < 5; layer++) {
|
|
if (!model.textures[`layer${layer}`]) continue;
|
|
const id = addTexture(model.textures[`layer${layer}`]);
|
|
const tint = ref.tints[layer];
|
|
const color = tint?.value ?? tint?.default;
|
|
parts.push({ generated: true, texture: id, png: textures[id], frame: frames[id], transform,
|
|
tint: typeof color === 'number' ? color & 0xffffff : 0xffffff });
|
|
}
|
|
} else if (Array.isArray(model.elements)) {
|
|
if (model.elements.length > 2048) throw new Error('Item model exceeds the preview element limit.');
|
|
const elements = clone(model.elements);
|
|
for (const element of elements) for (const face of Object.values(element.faces || {})) face.texture = addTexture(face.texture);
|
|
parts.push({ elements, textures, frames, transform,
|
|
tints: ref.tints.map((tint) => typeof (tint.value ?? tint.default) === 'number' ? (tint.value ?? tint.default) & 0xffffff : 0xffffff) });
|
|
} else throw new Error(`Model '${ref.id}' requires a special renderer. Use a manual texture preview.`);
|
|
}
|
|
if (!parts.length) throw new Error('The item model has no previewable geometry.');
|
|
return { parts, warnings: [...new Set(warnings)] };
|
|
}
|
|
|
|
function readPropPreviews() {
|
|
try { return JSON.parse(Project?.[PROP_PREVIEWS_PROPERTY] || '{}'); } catch (_) { return {}; }
|
|
}
|
|
|
|
function propEntryKey(animation, actor, slot, stage) { return JSON.stringify([animation, actor, slot, Number(stage)]); }
|
|
|
|
function effectivePropEntry(entries, animation, actor, slot, stage) {
|
|
return entries[propEntryKey(animation, actor, slot, stage)] ?? entries[propEntryKey(animation, actor, slot, 0)];
|
|
}
|
|
|
|
function effectivePropItem(definition, actor, slot, stageNumber) {
|
|
const field = `prop_${slot}`, stage = definition.stages?.find((stage) => stage.stage === stageNumber);
|
|
if (stage?.props === null || stage?.props?.[actor] === null) return null;
|
|
const props = stage?.props?.[actor];
|
|
if (props && Object.hasOwn(props, field)) return props[field];
|
|
return definition.actors?.find((entry) => entry.label === actor)?.[field];
|
|
}
|
|
|
|
function writePropDefinition(definition, actor, slot, stage, mode, item) {
|
|
const result = clone(definition), field = `prop_${slot}`;
|
|
if (!['item','hidden','inherit'].includes(mode) || !Number.isInteger(Number(stage)) || Number(stage) < 0) throw new Error('Choose a valid prop behavior and stage.');
|
|
if (!PROP_SLOTS[slot] || !result.actors?.some((entry) => entry.label === actor)) throw new Error('Choose a valid actor and prop slot.');
|
|
if (mode === 'item') propResourcePath(item, 'items', '.json');
|
|
if (!stage) {
|
|
const entry = result.actors.find((entry) => entry.label === actor);
|
|
if (mode === 'item') entry[field] = item; else delete entry[field];
|
|
return result;
|
|
}
|
|
const entry = result.stages.find((entry) => entry.stage === Number(stage));
|
|
if (!entry) throw new Error(`Missing definition stage p${stage}.`);
|
|
// Expanding an explicit all-hidden override must not accidentally unhide other slots/actors.
|
|
if (entry.props === null) entry.props = Object.fromEntries(result.actors.map((entry) => [entry.label, null]));
|
|
entry.props ||= {};
|
|
if (entry.props[actor] === null) entry.props[actor] = { prop_left: null, prop_right: null, prop_floor: null };
|
|
entry.props[actor] ||= {};
|
|
if (mode === 'inherit') delete entry.props[actor][field];
|
|
else entry.props[actor][field] = mode === 'hidden' ? null : item;
|
|
if (!Object.keys(entry.props[actor]).length) delete entry.props[actor];
|
|
if (!Object.keys(entry.props).length) delete entry.props;
|
|
return result;
|
|
}
|
|
|
|
function propAuthoringDefinition(id) {
|
|
const group = collectConjoinedAnimationGroups().groups.find((group) => group.id === id);
|
|
if (!group) throw new Error('Select an animation with actor tracks first.');
|
|
const analysis = analyzeAnimationDefinitionGroup(group), saved = getSavedAnimdef(id);
|
|
const actors = analysis.actorIndices.map((index) => {
|
|
const label = analysis.actorLabels.get(index), old = saved.actors?.find((actor) => actor.label === label);
|
|
return { ...buildActorConstraint(label, getProjectActorAnimdefMetadata()[index] || {}), ...old, label };
|
|
});
|
|
const stages = saved.stages?.length ? saved.stages : analysis.stages.map((stage) => ({ stage: stage.stageNumber, loop: stage.loop, cycle_seconds: stage.cycleSeconds }));
|
|
return { ...saved, actors, stages };
|
|
}
|
|
|
|
async function decodePropPng(url, frame) {
|
|
if (typeof Image === 'undefined' || typeof document === 'undefined') throw new Error('Image previews require Blockbench.');
|
|
const image = await new Promise((resolve, reject) => {
|
|
const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Could not decode the preview PNG.')); image.src = url;
|
|
});
|
|
if (image.width > 1024 || image.height > 8192 || !image.width || !image.height) throw new Error('Preview PNG dimensions are unsupported.');
|
|
const width = frame?.width || image.width, height = frame ? frame.height || width : image.height;
|
|
const first = frame?.frames?.[0], index = typeof first === 'number' ? first : first?.index || 0;
|
|
const columns = Math.floor(image.width / width), x = (index % columns) * width, y = Math.floor(index / columns) * height;
|
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width * height > 65536 || !Number.isInteger(index)
|
|
|| index < 0 || width <= 0 || height <= 0 || x + width > image.width || y + height > image.height) {
|
|
throw new Error('Invalid or oversized animated texture frame.');
|
|
}
|
|
const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
|
|
const context = canvas.getContext('2d'); context.drawImage(image,x,y,width,height,0,0,width,height);
|
|
return { width, height, pixels: context.getImageData(0,0,width,height).data, canvas };
|
|
}
|
|
|
|
function extrudePropPixels(width, height, pixels) {
|
|
if (width * height > 65536) throw new Error('Extruded previews are limited to 65,536 pixels.');
|
|
const elements = [], solid = (x, y) => x >= 0 && y >= 0 && x < width && y < height && pixels[(y * width + x) * 4 + 3] > 0;
|
|
// Front/back use one alpha-cutout plane. Only silhouette edges need thickness geometry.
|
|
const faces = { north: { texture: 'sprite', uv: [16, 0, 0, 16] }, south: { texture: 'sprite', uv: [0, 0, 16, 16] } };
|
|
elements.push({ from: [0, 0, 7.5], to: [16, 16, 8.5], faces });
|
|
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) if (solid(x, y)) {
|
|
const edges = {};
|
|
const uv = [(x + 0.5) * 16 / width, (y + 0.5) * 16 / height];
|
|
const face = () => ({ texture: 'sprite', uv: [...uv, ...uv] });
|
|
if (!solid(x - 1, y)) edges.west = face();
|
|
if (!solid(x + 1, y)) edges.east = face();
|
|
if (!solid(x, y - 1)) edges.up = face();
|
|
if (!solid(x, y + 1)) edges.down = face();
|
|
if (Object.keys(edges).length) elements.push({ from: [x * 16 / width, 16 - (y + 1) * 16 / height, 7.5],
|
|
to: [(x + 1) * 16 / width, 16 - y * 16 / height, 8.5], faces: edges });
|
|
}
|
|
return elements;
|
|
}
|
|
|
|
function propFaceVertices(from, to, face) {
|
|
const [x, y, z] = from, [X, Y, Z] = to;
|
|
return { north: [[X,y,z],[x,y,z],[x,Y,z],[X,Y,z]], south: [[x,y,Z],[X,y,Z],[X,Y,Z],[x,Y,Z]],
|
|
west: [[x,y,z],[x,y,Z],[x,Y,Z],[x,Y,z]], east: [[X,y,Z],[X,y,z],[X,Y,z],[X,Y,Z]],
|
|
up: [[x,Y,Z],[X,Y,Z],[X,Y,z],[x,Y,z]], down: [[x,y,z],[X,y,z],[X,y,Z],[x,y,Z]] }[face];
|
|
}
|
|
|
|
function propDefaultUv(from, to, face) {
|
|
const [x,y,z] = from, [X,Y,Z] = to;
|
|
return { north: [16-X,16-Y,16-x,16-y], south: [x,16-Y,X,16-y], west: [z,16-Y,Z,16-y],
|
|
east: [16-Z,16-Y,16-z,16-y], up: [x,z,X,Z], down: [x,16-Z,X,16-z] }[face];
|
|
}
|
|
|
|
async function buildPropMesh(asset) {
|
|
const root = new THREE.Group(), images = new Map();
|
|
try {
|
|
for (const [partIndex, part] of asset.parts.entries()) {
|
|
const pngs = part.generated ? { sprite: part.png } : part.textures;
|
|
const partImages = new Map();
|
|
for (const [id, url] of Object.entries(pngs)) {
|
|
const frame = part.generated ? part.frame : part.frames?.[id], key = url + JSON.stringify(frame || null);
|
|
if (!images.has(key)) images.set(key, await decodePropPng(url, frame));
|
|
partImages.set(id, images.get(key));
|
|
}
|
|
const sprite = partImages.get('sprite');
|
|
const elements = part.generated ? extrudePropPixels(sprite.width, sprite.height, sprite.pixels) : part.elements;
|
|
const transform = new THREE.Group(); root.add(transform);
|
|
transform.position.fromArray(part.transform.translation);
|
|
transform.rotation.set(...part.transform.rotation.map((value) => value * Math.PI / 180), 'XYZ');
|
|
transform.scale.fromArray(part.transform.scale);
|
|
const centered = new THREE.Group(); centered.position.set(-8, -8, -8); transform.add(centered);
|
|
const buffers = new Map();
|
|
for (const element of elements) {
|
|
const from = v3(element.from), to = v3(element.to);
|
|
const rotation = element.rotation;
|
|
const matrix = new THREE.Matrix4();
|
|
if (rotation) {
|
|
if (!['x', 'y', 'z'].includes(rotation.axis)) throw new Error('Unsupported item element rotation axis.');
|
|
const pivot = v3(rotation.origin, 8), angle = num(rotation.angle) * Math.PI / 180;
|
|
const quaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(...['x','y','z'].map((axis) => axis === rotation.axis ? 1 : 0)), angle);
|
|
const scale = new THREE.Vector3(1,1,1);
|
|
if (rotation.rescale) for (const axis of ['x','y','z']) if (axis !== rotation.axis) scale[axis] = 1 / Math.cos(angle);
|
|
matrix.compose(new THREE.Vector3(...pivot), quaternion, scale).multiply(new THREE.Matrix4().makeTranslation(...pivot.map((value) => -value)));
|
|
}
|
|
for (const [side, face] of Object.entries(element.faces || {})) {
|
|
const vertices = propFaceVertices(from, to, side);
|
|
if (!vertices || !pngs[face.texture]) continue;
|
|
const tint = part.tint ?? part.tints?.[face.tintindex] ?? 0xffffff, key = `${face.texture}:${tint}`;
|
|
if (!buffers.has(key)) buffers.set(key, { texture: face.texture, tint, positions: [], uvs: [] });
|
|
const buffer = buffers.get(key), uv = face.uv || propDefaultUv(from,to,side);
|
|
const coords = [[uv[0],uv[3]],[uv[2],uv[3]],[uv[2],uv[1]],[uv[0],uv[1]]];
|
|
const turns = ((face.rotation || 0) / 90) % 4;
|
|
for (let count = 0; count < turns; count++) coords.unshift(coords.pop());
|
|
for (const index of [0,1,2,0,2,3]) {
|
|
const vector = new THREE.Vector3(...vertices[index]).applyMatrix4(matrix);
|
|
buffer.positions.push(vector.x,vector.y,vector.z);
|
|
buffer.uvs.push(coords[index][0]/16, 1-coords[index][1]/16);
|
|
}
|
|
}
|
|
}
|
|
for (const buffer of buffers.values()) {
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(buffer.positions,3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(buffer.uvs,2)); geometry.computeVertexNormals();
|
|
const texture = new THREE.CanvasTexture(partImages.get(buffer.texture).canvas);
|
|
texture.magFilter = THREE.NearestFilter; texture.minFilter = THREE.NearestFilter;
|
|
if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace;
|
|
else if (THREE.sRGBEncoding) texture.encoding = THREE.sRGBEncoding;
|
|
const material = new THREE.MeshLambertMaterial({ map: texture, side: THREE.DoubleSide, alphaTest: 0.01,
|
|
transparent: false, color: buffer.tint, polygonOffset: !!part.generated,
|
|
polygonOffsetFactor: -partIndex, polygonOffsetUnits: -partIndex });
|
|
centered.add(new THREE.Mesh(geometry, material));
|
|
}
|
|
}
|
|
return root;
|
|
} catch (error) { disposePropMesh(root); throw error; }
|
|
}
|
|
|
|
function disposePropMesh(root) {
|
|
root.parent?.remove(root);
|
|
root.traverse((node) => { node.geometry?.dispose(); if (node.material) { node.material.map?.dispose(); node.material.dispose(); } });
|
|
}
|
|
|
|
function clearPropMeshes() {
|
|
propPreviewGeneration++;
|
|
for (const mesh of propMeshes.values()) disposePropMesh(mesh);
|
|
propMeshes.clear(); propPreviewSignature = null;
|
|
}
|
|
|
|
async function refreshPropPreviews(force = false) {
|
|
const selected = typeof Animation !== 'undefined' ? parseAfwStageAnimationName(getSelectedAnimation()) : null;
|
|
const raw = Project?.[PROP_PREVIEWS_PROPERTY] || '{}';
|
|
if (raw === '{}' && !propMeshes.size) return;
|
|
const bones = typeof Group !== 'undefined' ? Group.all.filter((group) => /_prop(left|right|floor)$/.test(group.name)).map((group) => `${group.uuid}:${group.name}`).join(',') : '';
|
|
const definitions = Project?.[ANIMDEF_DEFINITIONS_PROPERTY];
|
|
const signature = { project: Project?.uuid, animation: selected?.name, raw, bones, definitions };
|
|
if (!force && signature.project === propPreviewSignature?.project && signature.animation === propPreviewSignature?.animation
|
|
&& raw === propPreviewSignature?.raw && bones === propPreviewSignature?.bones && definitions === propPreviewSignature?.definitions) {
|
|
for (const [uuid, mesh] of propMeshes) {
|
|
const bone = Group.all.find((group) => group.uuid === uuid);
|
|
if (bone?.mesh && mesh.parent !== bone.mesh) bone.mesh.add(mesh);
|
|
}
|
|
return;
|
|
}
|
|
clearPropMeshes(); propPreviewSignature = signature;
|
|
if (!Project || !selected || typeof THREE === 'undefined') return;
|
|
const generation = propPreviewGeneration, entries = readPropPreviews(), definition = getSavedAnimdef(selected.id);
|
|
for (const index of getSortedUsedActorIndicesFromProject()) for (const slot of Object.keys(PROP_SLOTS)) {
|
|
const actor = getActorExportLabel(index), entry = effectivePropEntry(entries, selected.id, actor, slot, selected.stageNumber);
|
|
if (!entry?.asset || entry.mode !== 'item') continue;
|
|
const item = effectivePropItem(definition, actor, slot, selected.stageNumber);
|
|
if (item === null) continue;
|
|
const bone = Group.all.find((group) => group.name === `actor${index}_${PROP_SLOTS[slot]}`);
|
|
if (!bone?.mesh) continue;
|
|
try {
|
|
const asset = item && item !== entry.item ? (!entry.texture && entry.source ? loadPropAsset(openPropSource(entry.source),item,slot) : null) : entry.asset;
|
|
if (!asset) continue;
|
|
const mesh = await buildPropMesh(asset);
|
|
if (generation !== propPreviewGeneration) { disposePropMesh(mesh); return; }
|
|
bone.mesh.add(mesh); propMeshes.set(bone.uuid, mesh);
|
|
} catch (error) { console.warn('Could not build item prop preview:', error); }
|
|
}
|
|
}
|
|
|
|
async function applyPropPreview(values) {
|
|
const projectId = Project?.uuid;
|
|
if (!projectId) throw new Error('Open an animation project first.');
|
|
const definition = propAuthoringDefinition(values.animation), stage = Number(values.stage), slot = values.slot, actor = values.actor;
|
|
const item = String(values.item || '').trim();
|
|
const updated = writePropDefinition(definition, actor, slot, stage, values.mode, item);
|
|
let asset = null;
|
|
if (values.mode === 'item') {
|
|
if (values.texture) {
|
|
const io = desktopFiles(), png = io.fs.readFileSync(values.texture);
|
|
if (png.length > 4 * 1024 * 1024) throw new Error('Manual texture exceeds the 4 MiB limit.');
|
|
asset = { parts: [{ generated: true, png: `data:image/png;base64,${Buffer.from(png).toString('base64')}`,
|
|
transform: propDisplayTransform(manualPropModel(values.preset),slot) }], warnings: [] };
|
|
} else {
|
|
if (!values.source) throw new Error('Select a Minecraft JAR/assets folder or a manual PNG texture.');
|
|
asset = loadPropAsset(openPropSource(values.source), item, slot);
|
|
}
|
|
// Validate image/geometry before changing project metadata or opening an undo edit.
|
|
const validation = await buildPropMesh(asset); disposePropMesh(validation);
|
|
}
|
|
if (Project?.uuid !== projectId) throw new Error('The active project changed. Try again.');
|
|
const key = propEntryKey(values.animation,actor,slot,stage), entries = readPropPreviews();
|
|
if (values.mode === 'inherit') delete entries[key];
|
|
else entries[key] = { animation: values.animation, actor, slot, stage, mode: values.mode, item,
|
|
source: values.source, texture: values.texture, preset: values.preset, asset };
|
|
const raw = JSON.stringify(entries);
|
|
if (raw.length > 24 * 1024 * 1024) throw new Error('Project prop previews exceed the 24 MiB embedded asset limit.');
|
|
Undo.initEdit({ prop_preview_edit: true, animdef_edit: true });
|
|
Project[PROP_PREVIEWS_PROPERTY] = raw; saveAnimdef(values.animation, updated);
|
|
Undo.finishEdit('Edit item prop', { prop_preview_edit: true, animdef_edit: true });
|
|
Project.saved = false;
|
|
await refreshPropPreviews(true);
|
|
return asset?.warnings || [];
|
|
}
|
|
|
|
function createPropDialog() {
|
|
const animationIds = collectConjoinedAnimationGroups().groups.map((group) => group.id);
|
|
const selected = parseAfwStageAnimationName(getSelectedAnimation());
|
|
const state = {
|
|
projectId: Project.uuid, animation: selected && animationIds.includes(selected.id) ? selected.id : animationIds[0] || '',
|
|
actor: '', slot: 'right', stage: '0', source: '', jar: '', folder: '', search: '', choice: '',
|
|
actors: [], stages: [], sources: discoverPropSources(), items: [], busy: false,
|
|
};
|
|
const addSource = (file) => {
|
|
if (file && !state.sources.some((entry) => entry.file === file)) state.sources.push({ file, label: `Selected: ${file}` });
|
|
};
|
|
const indexItems = () => {
|
|
state.items = [];
|
|
if (!state.source) return;
|
|
const source = openPropSource(state.source);
|
|
if (source.files) {
|
|
const modern = [...source.files.keys()].filter((name) => /^assets\/[^/]+\/items\/.+\.json$/.test(name));
|
|
state.items = modern.length ? modern.map((name) => name.replace(/^assets\/([^/]+)\/items\/(.+)\.json$/, '$1:$2')).sort()
|
|
: [...source.files.keys()].filter((name) => /^assets\/[^/]+\/models\/item\/.+\.json$/.test(name))
|
|
.map((name) => name.replace(/^assets\/([^/]+)\/models\/item\/(.+)\.json$/, '$1:$2')).sort();
|
|
} else state.items = source.itemIds();
|
|
};
|
|
const selectAnimation = () => {
|
|
const definition = state.animation ? propAuthoringDefinition(state.animation) : { actors: [], stages: [] };
|
|
state.actors = definition.actors.map((actor) => actor.label);
|
|
state.stages = definition.stages.map((stage) => stage.stage);
|
|
if (!state.actors.includes(state.actor)) state.actor = state.actors[0] || '';
|
|
if (!state.stages.includes(Number(state.stage))) state.stage = '0';
|
|
};
|
|
const savedEntry = () => {
|
|
const entry = readPropPreviews()[propEntryKey(state.animation, state.actor, state.slot, state.stage)];
|
|
const definition = getSavedAnimdef(state.animation), field = `prop_${state.slot}`;
|
|
const fallback = definition.actors?.find((actor) => actor.label === state.actor)?.[field];
|
|
const stage = definition.stages?.find((stage) => stage.stage === Number(state.stage));
|
|
const override = stage?.props?.[state.actor];
|
|
const value = Number(state.stage) ? (stage?.props === null || override === null ? null : override?.[field]) : fallback;
|
|
if (entry?.source) { addSource(entry.source); state.source = entry.source; }
|
|
return {
|
|
mode: entry?.mode || (value === null ? 'hidden' : value !== undefined ? 'item' : Number(state.stage) ? 'inherit' : 'item'),
|
|
item: entry?.item || value || fallback || 'minecraft:carrot',
|
|
texture: entry?.texture || '', preset: entry?.preset || 'generated',
|
|
};
|
|
};
|
|
selectAnimation();
|
|
state.source = localStorage.getItem('animationdirector_prop_source') || state.sources[0]?.file || '';
|
|
const initial = savedEntry();
|
|
addSource(state.source);
|
|
try { indexItems(); } catch (error) { console.warn('Could not index item assets:', error); state.items = []; }
|
|
const form = {
|
|
_target: { type: 'info', text: '### Prop target\nChoose the actor bone and the stages that should use this item.' },
|
|
animation: { label: 'Animation', type: 'select', value: state.animation,
|
|
options: Object.fromEntries(animationIds.map((id) => [id, id])) },
|
|
actor: { label: 'Actor', type: 'select', value: state.actor,
|
|
options: () => Object.fromEntries(state.actors.map((id) => [id, id])) },
|
|
slot: { label: 'Prop slot', type: 'select', value: state.slot,
|
|
options: { right: 'Right hand', left: 'Left hand', floor: 'Floor' } },
|
|
stage: { label: 'Scope', type: 'select', value: state.stage,
|
|
options: () => ({ 0: 'Default (all stages)', ...Object.fromEntries(state.stages.map((id) => [String(id), `Stage p${id}`])) }) },
|
|
mode: { label: 'Behavior', type: 'select', value: initial.mode,
|
|
options: () => ({ item: 'Use item', ...state.stage !== '0' ? { hidden: 'Hide prop' } : {},
|
|
inherit: state.stage === '0' ? 'Remove default prop' : 'Inherit default prop' }) },
|
|
_assets: { type: 'info', text: '### Minecraft assets\nUse local Minecraft assets or a manual PNG for the editor preview. The preview is not exported.' ,
|
|
condition: (result) => result.mode === 'item' },
|
|
source: { label: 'Version / asset source', type: 'select', value: state.source,
|
|
options: () => ({ '': 'Select assets...', ...Object.fromEntries(state.sources.map((entry) => [entry.file, entry.label])) }),
|
|
condition: (result) => result.mode === 'item' },
|
|
jar: { label: 'Add client JAR', type: 'file', extensions: ['jar', 'zip'], readtype: 'none', value: '',
|
|
condition: (result) => result.mode === 'item' },
|
|
folder: { label: 'Add extracted assets folder', type: 'folder', value: '',
|
|
condition: (result) => result.mode === 'item' },
|
|
item: { label: 'Item ID', type: 'text', value: initial.item, placeholder: 'minecraft:carrot',
|
|
description: 'The in-game item saved to the animdef.', condition: (result) => result.mode === 'item' },
|
|
item_search: { label: 'Search available items', type: 'text', value: '', placeholder: 'Search by item ID',
|
|
condition: (result) => result.mode === 'item' && !!state.items.length },
|
|
item_choice: { label: 'Choose item', type: 'select', value: '',
|
|
options: () => propItemOptions(state.items, state.search),
|
|
condition: (result) => result.mode === 'item' && !!state.items.length },
|
|
_manual: { type: 'info', text: '### Manual texture fallback\nFor items with a special renderer, choose a PNG to preview an extruded sprite. This does not change the in-game model.',
|
|
condition: (result) => result.mode === 'item' },
|
|
texture: { label: 'Preview texture (optional)', type: 'file', extensions: ['png'], readtype: 'none', value: initial.texture,
|
|
condition: (result) => result.mode === 'item' },
|
|
preset: { label: 'Display preset', type: 'select', value: initial.preset,
|
|
options: { generated: 'Generated item (food, materials)', handheld: 'Handheld item (tools, swords)' },
|
|
condition: (result) => result.mode === 'item' && !!result.texture },
|
|
};
|
|
const dialog = new Dialog({ id: 'animationdirector_props', title: 'Item Props', width: 760, form,
|
|
buttons: ['Cancel', 'Apply prop'], cancelIndex: 0, confirmIndex: 1,
|
|
onFormChange(result) {
|
|
const nextSource = result.jar && result.jar !== state.jar ? result.jar
|
|
: result.folder && result.folder !== state.folder ? result.folder : result.source;
|
|
state.jar = result.jar; state.folder = result.folder;
|
|
if (nextSource !== state.source) {
|
|
state.source = nextSource; addSource(nextSource);
|
|
this.form.setValues({ source: nextSource, item_choice: '' }, false);
|
|
state.choice = '';
|
|
try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
|
|
}
|
|
if (result.animation !== state.animation) {
|
|
state.animation = result.animation; state.actor = ''; state.stage = '0'; selectAnimation();
|
|
const previousSource = state.source, entry = savedEntry();
|
|
this.form.setValues({ actor: state.actor, stage: state.stage, source: state.source, ...entry }, false);
|
|
if (state.source !== previousSource) try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
|
|
} else if (result.actor !== state.actor || result.slot !== state.slot || result.stage !== state.stage) {
|
|
state.actor = result.actor; state.slot = result.slot; state.stage = result.stage;
|
|
const previousSource = state.source, entry = savedEntry();
|
|
this.form.setValues({ source: state.source, ...entry }, false);
|
|
if (state.source !== previousSource) try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
|
|
}
|
|
if (result.item_search !== state.search) {
|
|
state.search = result.item_search || '';
|
|
state.choice = '';
|
|
this.form.setValues({ item_choice: '' }, false);
|
|
} else if (result.item_choice && result.item_choice !== state.choice) {
|
|
state.choice = result.item_choice;
|
|
this.form.setValues({ item: result.item_choice }, false);
|
|
}
|
|
this.form.update(this.form.getResult());
|
|
},
|
|
onConfirm(result) {
|
|
if (state.busy) return false;
|
|
state.busy = true;
|
|
const values = { animation: result.animation, actor: result.actor, slot: result.slot, stage: result.stage,
|
|
mode: result.mode, item: result.item, source: state.source, texture: result.texture, preset: result.preset };
|
|
void applyPropPreview(values).then((warnings) => {
|
|
localStorage.setItem('animationdirector_prop_source', state.source);
|
|
const index = getSortedUsedActorIndicesFromProject().find((index) => getActorExportLabel(index) === values.actor);
|
|
const missingBone = !Group.all.some((group) => group.name === `actor${index}_${PROP_SLOTS[values.slot]}`);
|
|
this.hide();
|
|
Blockbench.showQuickMessage([`Item prop saved.`, ...warnings,
|
|
...(missingBone ? [`Add the ${PROP_SLOTS[values.slot]} bone to see its preview.`] : [])].join(' '), 4500);
|
|
}).catch((error) => {
|
|
Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] });
|
|
}).finally(() => { state.busy = false; });
|
|
return false;
|
|
},
|
|
});
|
|
propDialog = dialog;
|
|
}
|
|
|
|
function showPropDialog() {
|
|
if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500);
|
|
try {
|
|
if (!collectConjoinedAnimationGroups().groups.length) {
|
|
Blockbench.showQuickMessage('Create or import an animation before editing item props.', 3000);
|
|
return;
|
|
}
|
|
propDialog?.delete();
|
|
createPropDialog();
|
|
propDialog.show();
|
|
} catch (error) {
|
|
Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] });
|
|
}
|
|
}
|
|
|
|
function desktopFiles() {
|
|
const load = typeof requireNativeModule === 'function' ? requireNativeModule
|
|
: typeof require === 'function' ? require : null;
|
|
if (!load) throw new Error('Automatic import requires desktop Blockbench. The Manual Import tools remain available in the browser.');
|
|
const fs = load('fs'), path = load('path');
|
|
if (!fs || !['readFileSync', 'statSync', 'existsSync', 'readdirSync'].every((method) => typeof fs[method] === 'function')) {
|
|
throw new Error('Filesystem access is unavailable. Allow filesystem access for AnimationDirector and try again.');
|
|
}
|
|
return { fs, path };
|
|
}
|
|
|
|
function importFilePath(file, io) {
|
|
if (typeof io.fs.realpathSync === 'function') return io.fs.realpathSync(file);
|
|
const resolved = io.path.resolve(file);
|
|
// Blockbench's scoped filesystem omits realpath/lstat. Dirent can still detect links.
|
|
// Reject linked ancestors rather than silently weakening resource containment checks.
|
|
io.checkedImportPaths ||= new Set();
|
|
const check = (candidate) => {
|
|
if (io.checkedImportPaths.has(candidate)) return;
|
|
const parent = io.path.dirname(candidate);
|
|
if (parent !== candidate) {
|
|
check(parent);
|
|
io.importDirectoryEntries ||= new Map();
|
|
if (!io.importDirectoryEntries.has(parent)) {
|
|
io.importDirectoryEntries.set(parent, io.fs.readdirSync(parent, { withFileTypes: true }));
|
|
}
|
|
const name = io.path.basename(candidate);
|
|
const entry = io.importDirectoryEntries.get(parent).find((item) => io.path.sep === '\\'
|
|
? item.name.toLowerCase() === name.toLowerCase() : item.name === name);
|
|
if (entry?.isSymbolicLink()) throw new Error(`Linked paths are not supported by this Blockbench filesystem. Select the actual extracted pack folder: ${candidate}`);
|
|
}
|
|
io.checkedImportPaths.add(candidate);
|
|
};
|
|
check(resolved);
|
|
return resolved;
|
|
}
|
|
|
|
function savedPackFolders() {
|
|
try {
|
|
const value = JSON.parse(localStorage.getItem(PACK_FOLDERS_KEY) || '[]');
|
|
return Array.isArray(value) ? uniqueTrimmedStrings(value.filter((item) => typeof item === 'string')) : [];
|
|
} catch (_) { return []; }
|
|
}
|
|
|
|
function readImportJson(file, io) {
|
|
if (io.fs.statSync(file).size > 128 * 1024 * 1024) throw new Error(`JSON file is too large: ${file}`);
|
|
return JSON.parse(io.fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
}
|
|
|
|
function insidePack(root, relative, io) {
|
|
const file = io.path.resolve(root, relative);
|
|
const contained = (candidate) => {
|
|
const rel = io.path.relative(root, candidate);
|
|
return rel !== '..' && !rel.startsWith(`..${io.path.sep}`) && !io.path.isAbsolute(rel);
|
|
};
|
|
if (!contained(file)) throw new Error(`Resource path escapes its pack: ${relative}`);
|
|
if (!io.fs.existsSync(file)) return null;
|
|
const real = importFilePath(file, io);
|
|
if (!contained(real)) throw new Error(`Resource symlink escapes its pack: ${relative}`);
|
|
return real;
|
|
}
|
|
|
|
function importResourceId(value) {
|
|
if (typeof value !== 'string' || !RESOURCE_ID_PATTERN.test(value)) return null;
|
|
const [namespace, resource] = value.split(':');
|
|
if (resource.split('/').some((part) => !part || part === '.' || part === '..')) return null;
|
|
return { namespace, resource };
|
|
}
|
|
|
|
function readImportPack(folder, io) {
|
|
const root = importFilePath(folder, io);
|
|
const metadata = readImportJson(insidePack(root, 'pack.mcmeta', io) || (() => {
|
|
throw new Error(`Select an extracted pack folder containing pack.mcmeta: ${folder}`);
|
|
})(), io);
|
|
const afw = metadata.animationframework || {};
|
|
return { root, metadata: afw, id: importResourceId(afw.id) ? afw.id : null,
|
|
name: typeof afw.name === 'string' ? afw.name : io.path.basename(root) };
|
|
}
|
|
|
|
function findImportPack(file, io) {
|
|
let folder = io.path.dirname(io.path.resolve(file));
|
|
while (true) {
|
|
if (io.fs.existsSync(io.path.join(folder, 'pack.mcmeta'))) return readImportPack(folder, io);
|
|
const parent = io.path.dirname(folder);
|
|
if (parent === folder) throw new Error('The selected file must be inside an extracted pack containing pack.mcmeta. ZIP files are not supported.');
|
|
folder = parent;
|
|
}
|
|
}
|
|
|
|
function importPackRegistry(source, folders, io) {
|
|
const packs = [], warnings = [], seen = new Set();
|
|
// An unregistered source goes first; registered sources retain their configured priority.
|
|
for (const folder of [ ...folders, source.root ]) {
|
|
try {
|
|
const pack = readImportPack(folder, io);
|
|
if (!seen.has(pack.root)) { packs.push(pack); seen.add(pack.root); }
|
|
} catch (error) { warnings.push(error.message); }
|
|
}
|
|
if (!folders.some((folder) => {
|
|
try { return importFilePath(folder, io) === source.root; } catch (_) { return false; }
|
|
})) {
|
|
const index = packs.findIndex((pack) => pack.root === source.root);
|
|
if (index >= 0) packs.unshift(...packs.splice(index, 1));
|
|
}
|
|
const ids = new Set();
|
|
for (const pack of packs) {
|
|
if (pack.id && ids.has(pack.id)) warnings.push(`Duplicate pack ID ${pack.id}; configured folder priority decides resource overrides.`);
|
|
if (pack.id) ids.add(pack.id);
|
|
}
|
|
return { packs, warnings };
|
|
}
|
|
|
|
function findImportResource(packs, id, directory, extension, io) {
|
|
const parsed = importResourceId(id);
|
|
if (!parsed) return null;
|
|
const relative = `assets/${parsed.namespace}/${directory}${parsed.resource}${extension}`;
|
|
for (const pack of packs) {
|
|
const file = insidePack(pack.root, relative, io);
|
|
if (file && io.fs.statSync(file).isFile()) return { file, pack, id };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function importModelCandidates(entity, gender, variant, slim = false) {
|
|
const parsed = importResourceId(entity);
|
|
if (!parsed || !['m', 'f', 'mf'].includes(gender)) throw new Error('Choose a concrete entity ID and model gender for every actor.');
|
|
if (variant && (!/^[a-z0-9/._-]+$/.test(variant) || variant.split('/').some((part) => !part || part === '..' || part === '.'))) {
|
|
throw new Error(`Invalid entity variant: ${variant}`);
|
|
}
|
|
const paths = variant ? [`${parsed.resource}_${variant}`, parsed.resource] : [parsed.resource];
|
|
if (slim && entity === 'minecraft:player') paths.unshift('player_slim');
|
|
const fallbacks = gender === 'mf' ? ['m', 'f'] : [gender === 'm' ? 'f' : 'm'];
|
|
return [...paths.map((base) => `${base}.${gender}`), ...paths,
|
|
...fallbacks.flatMap((suffix) => paths.map((base) => `${base}.${suffix}`))];
|
|
}
|
|
|
|
function resolveImportModel(source, packs, entity, gender, variant, io, slim = false) {
|
|
const roots = [];
|
|
const addRoot = (id, kind) => {
|
|
if (roots.length < 32 && importResourceId(id) && !roots.some((root) => root.id === id)) roots.push({ id, kind });
|
|
};
|
|
if (source.id) addRoot(`${source.id}/entity`, 'Local');
|
|
for (const entry of Array.isArray(source.metadata.model_imports) ? source.metadata.model_imports : []) {
|
|
if (entry?.pack !== source.id && Array.isArray(entry?.entities) && entry.entities.includes(entity)) {
|
|
addRoot(`${entry.pack}/entity`, `Imported ${entry.pack}`);
|
|
}
|
|
}
|
|
const revision = (value) => Number.isInteger(value) && value > 0 ? value : 1;
|
|
const target = revision(source.metadata.default_model_revision);
|
|
const current = Math.max(1, ...packs.filter((pack) => pack.id === 'needsofnature:default_pack')
|
|
.map((pack) => revision(pack.metadata.default_model_revision)));
|
|
// Sparse archives: only changed models exist in each old revision directory.
|
|
for (let number = target; number < current && roots.length < 32; number++) {
|
|
addRoot(`needsofnature:default_pack_compat/revision_${number}/entity`, `Default revision ${number}`);
|
|
}
|
|
roots.push({ id: 'animationframework:entity', kind: 'Shared' });
|
|
roots.push({ id: 'animationframework:', kind: 'Shared legacy' });
|
|
// Gender-specific candidates precede generic candidates across ALL providers, as in NoN.
|
|
for (const candidate of importModelCandidates(entity, gender, variant, slim)) {
|
|
for (const root of roots) {
|
|
const id = root.id.endsWith(':') ? root.id + candidate : `${root.id}/${candidate}`;
|
|
const found = findImportResource(packs, id, 'geckolib/models/', '.geo.json', io);
|
|
if (found) return { ...found, kind: root.kind };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function definitionActorKeys(actors) {
|
|
const sanitize = (value) => String(value).toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
const counts = new Map();
|
|
return actors.map((actor) => {
|
|
let base;
|
|
if (actor.label) base = sanitize(actor.label);
|
|
else {
|
|
base = (actor.entity_types || []).map((id) => String(id).split(':').pop()).sort().join('_') || 'actor';
|
|
if (actor.actor_tags?.length) base += '_' + [...actor.actor_tags].sort().map(sanitize).join('_');
|
|
if (actor.actor_tags_any?.length) base += '_any_' + [...actor.actor_tags_any].sort().map(sanitize).join('_');
|
|
if (actor.entity_variant) base += '_' + actor.entity_variant;
|
|
if (actor.age === 'baby') base += '_baby';
|
|
base = sanitize(base);
|
|
}
|
|
const count = (counts.get(base) || 0) + 1;
|
|
counts.set(base, count);
|
|
return count === 1 ? base : `${base}_${count}`;
|
|
});
|
|
}
|
|
|
|
function inferImportGender(actor) {
|
|
const required = actor.actor_tags || [];
|
|
const male = required.includes('gender.male'), female = required.includes('gender.female');
|
|
// Matching either flag also permits male+female actors: let creators select the intended model.
|
|
if (male && female) return 'mf';
|
|
return '';
|
|
}
|
|
|
|
function defaultImportClipRoles(actors, clipLabels) {
|
|
const keys = definitionActorKeys(actors);
|
|
const assigned = actors.map((_, index) => clipLabels.includes(keys[index]) ? keys[index] : '');
|
|
const entityNames = actors.map((actor) => actor.entity_types?.length === 1
|
|
? String(actor.entity_types[0]).split(':').pop() : '');
|
|
actors.forEach((_, index) => {
|
|
const name = entityNames[index];
|
|
// Older clips often omit constraint tags. Only infer a short label when unambiguous.
|
|
if (!assigned[index] && name && clipLabels.includes(name) && !assigned.includes(name)
|
|
&& entityNames.filter((candidate, position) => candidate === name && !assigned[position]).length === 1) assigned[index] = name;
|
|
});
|
|
return assigned;
|
|
}
|
|
|
|
function validateImportGeometry(json) {
|
|
const geo = extractGeckoGeometryRoot(json);
|
|
if (!geo || !Array.isArray(geo.bones) || !geo.bones.length) throw new Error('The model must contain a non-empty minecraft:geometry bones array.');
|
|
const bones = new Map();
|
|
for (const bone of geo.bones) {
|
|
if (!bone || typeof bone.name !== 'string' || !bone.name || bones.has(bone.name)) throw new Error('Model bone names must be non-empty and unique.');
|
|
bones.set(bone.name, bone);
|
|
}
|
|
const completed = new Set();
|
|
for (const bone of bones.values()) {
|
|
const visited = new Set();
|
|
let cursor = bone;
|
|
while (cursor && !completed.has(cursor.name)) {
|
|
if (visited.has(cursor.name)) throw new Error(`Cyclic model hierarchy at ${cursor.name}.`);
|
|
visited.add(cursor.name);
|
|
cursor = bones.get(cursor.parent);
|
|
}
|
|
for (const name of visited) completed.add(name);
|
|
}
|
|
return geo;
|
|
}
|
|
|
|
function companionImportFiles(source, file, io) {
|
|
const relative = io.path.relative(source.root, io.path.resolve(file)).replace(/\\/g, '/');
|
|
const definition = /^data\/([^/]+)\/afw_animdefs\/(.+)\.json$/.exec(relative);
|
|
const animation = /^assets\/([^/]+)\/(?:geckolib\/)?animations\/afw\/(.+)\.animation\.json$/.exec(relative);
|
|
const match = definition || animation;
|
|
if (!match || !importResourceId(`${match[1]}:${match[2]}`)) {
|
|
throw new Error('Choose data/<namespace>/afw_animdefs/<ID>.json or assets/<namespace>/geckolib/animations/afw/<ID>.animation.json.');
|
|
}
|
|
let id = match[2];
|
|
const parts = id.split('/');
|
|
if (animation && parts.length > 1 && parts.at(-1) === parts.at(-2)) id = parts.slice(0, -1).join('/');
|
|
const namespace = match[1];
|
|
const namespaces = (kind) => {
|
|
const folder = insidePack(source.root, kind, io);
|
|
return folder ? io.fs.readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
|
|
};
|
|
const candidates = (kind, middles, names, suffix) => [...new Set([namespace, ...namespaces(kind)])]
|
|
.flatMap((ns) => middles.flatMap((middle) => names.map((name) => insidePack(source.root, `${kind}/${ns}/${middle}/${name}${suffix}`, io)))).filter(Boolean);
|
|
return { id,
|
|
definitions: definition ? [io.path.resolve(file)] : candidates('data', ['afw_animdefs'], [id], '.json'),
|
|
animations: animation ? [io.path.resolve(file)] : candidates('assets', ['geckolib/animations/afw', 'animations/afw'],
|
|
[id, `${id}/${id.split('/').pop()}`], '.animation.json') };
|
|
}
|
|
|
|
function importDialog(title, form, confirmText = 'Continue') {
|
|
return new Promise((resolve) => new Dialog({ id: 'animationdirector_automatic_import_step', title, width: 680, form,
|
|
buttons: ['Cancel', confirmText], cancelIndex: 0, confirmIndex: 1,
|
|
onConfirm(result) { resolve(result); }, onCancel() { resolve(null); } }).show());
|
|
}
|
|
|
|
function pickImportFile(title) {
|
|
return new Promise((resolve) => Filesystem.importFile({ type: 'JSON', title, extensions: ['json'],
|
|
multiple: false, readtype: 'text' }, (files) => resolve(files?.[0] || null)));
|
|
}
|
|
|
|
async function chooseCompanion(files, title) {
|
|
if (files.length === 1) return files[0];
|
|
if (!files.length) return (await pickImportFile(`${title} (not found in pack)`))?.path || null;
|
|
const result = await importDialog(title, { file: { type: 'select', label: 'File', value: files[0],
|
|
options: { '': 'Choose a file...', ...Object.fromEntries(files.map((file) => [file, file])) } } });
|
|
if (!result) return null;
|
|
if (!files.includes(result.file)) throw new Error('Select the companion file explicitly.');
|
|
return result.file;
|
|
}
|
|
|
|
function editImportPackFolders() {
|
|
const io = desktopFiles();
|
|
const folders = savedPackFolders();
|
|
const dialog = new Dialog({ id: 'animationdirector_pack_folders', title: 'Animation Import Pack Folders', width: 680,
|
|
component: {
|
|
data() { return { folders, error: '' }; },
|
|
methods: {
|
|
add() {
|
|
const folder = Blockbench.pickDirectory({ title: 'Select extracted pack folder', resource_id: PACK_FOLDERS_KEY });
|
|
if (!folder) return;
|
|
try {
|
|
const pack = readImportPack(folder, io);
|
|
if (!this.folders.includes(pack.root)) this.folders.push(pack.root);
|
|
this.error = '';
|
|
} catch (error) { this.error = error.message; }
|
|
},
|
|
move(index, direction) {
|
|
const next = index + direction;
|
|
if (next < 0 || next >= this.folders.length) return;
|
|
const item = this.folders.splice(index, 1)[0];
|
|
this.folders.splice(next, 0, item);
|
|
},
|
|
},
|
|
template: `<div style="padding:12px"><p>Highest priority first. Add the default pack and any model-import provider packs. These folders are remembered for future imports; Minecraft's load order is unchanged.</p>
|
|
<div v-for="(folder,index) in folders" :key="folder" style="display:flex;gap:6px;margin:8px 0;align-items:center">
|
|
<span style="flex:1;overflow-wrap:anywhere">{{folder}}</span>
|
|
<button @click="move(index,-1)" :disabled="index===0">Up</button><button @click="move(index,1)" :disabled="index===folders.length-1">Down</button>
|
|
<button @click="folders.splice(index,1)">Remove</button></div>
|
|
<button @click="add">Add Pack Folder</button><p style="color:var(--color-error)">{{error}}</p></div>`,
|
|
},
|
|
onConfirm() { localStorage.setItem(PACK_FOLDERS_KEY, JSON.stringify(folders)); },
|
|
});
|
|
dialog.show();
|
|
}
|
|
|
|
function loadImportTextures(row, packs, io) {
|
|
const ids = new Set(Object.values(row.json.afw_bone_textures || {}).filter((value) => typeof value === 'string'));
|
|
if (typeof row.json.afw_texture === 'string') ids.add(row.json.afw_texture);
|
|
else {
|
|
const entity = importResourceId(row.entity);
|
|
for (const resource of [`${entity.namespace}:textures/entity/${entity.resource}/${entity.resource}.png`,
|
|
`${entity.namespace}:textures/entity/${entity.resource}.png`]) {
|
|
if (findImportResource(packs, resource, '', '', io)) { row.defaultTexture = resource; ids.add(resource); break; }
|
|
}
|
|
}
|
|
if (typeof row.json.afw_texture === 'string') row.defaultTexture = row.json.afw_texture;
|
|
row.textures = new Map();
|
|
for (const resource of ids) {
|
|
const id = resource.includes(':') ? resource : `minecraft:${resource}`;
|
|
const texture = findImportResource(packs, id, '', '', io);
|
|
if (texture) row.textures.set(resource, texture);
|
|
}
|
|
}
|
|
|
|
function commitAutomaticImport(plan) {
|
|
if (!Formats.geckolib_model) throw new Error('Install/enable the GeckoLib Blockbench plugin before importing an animation.');
|
|
cancelBlockRequirementUpdate();
|
|
if (!newProject(Formats.geckolib_model)) throw new Error('Could not create the GeckoLib project.');
|
|
const projectId = Project.uuid;
|
|
const aspects = { outliner: true, selection: true, elements: [], groups: [], textures: [], animations: [],
|
|
automatic_animation_import: true };
|
|
Undo.initEdit(aspects);
|
|
try {
|
|
Project.name = plan.parsed.animationId;
|
|
const description = plan.rows[0]?.geo.description || {};
|
|
const dimension = (value) => Number.isFinite(value) && value > 0 ? value : 64;
|
|
Project.texture_width = dimension(description.texture_width);
|
|
Project.texture_height = dimension(description.texture_height);
|
|
const names = {}, textures = {}, metadata = {}, overrides = {}, loadedTextures = new Map();
|
|
for (const [offset, row] of plan.rows.entries()) {
|
|
const index = offset + 1;
|
|
names[index] = row.label;
|
|
metadata[index] = plan.definition.actors[offset];
|
|
importGeometryIntoCurrentProject(row.geo, index, false, false);
|
|
for (const [name, resource] of row.textures) {
|
|
if (!loadedTextures.has(resource.file)) {
|
|
const texture = new Texture({ name, uv_width: row.geo.description?.texture_width || 64,
|
|
uv_height: row.geo.description?.texture_height || 64 });
|
|
texture.keep_size = true;
|
|
texture.load_callback = () => { if (Project?.uuid === projectId) scheduleActorTexturePreviewApply(); };
|
|
texture.fromPath(resource.file);
|
|
texture.name = name;
|
|
texture.add(false);
|
|
loadedTextures.set(resource.file, name);
|
|
}
|
|
}
|
|
if (row.textures.has(row.defaultTexture)) textures[index] = loadedTextures.get(row.textures.get(row.defaultTexture).file);
|
|
for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) {
|
|
const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`);
|
|
if (group && row.textures.has(textureName)) overrides[group.uuid] = { uuid: group.uuid, name: group.name,
|
|
texture: loadedTextures.get(row.textures.get(textureName).file) };
|
|
}
|
|
}
|
|
setProjectActorNames(names);
|
|
setProjectActorTextures(textures);
|
|
setProjectActorAnimdefMetadata(metadata);
|
|
setProjectBoneTextureOverrides(overrides);
|
|
setProjectAnimdefAnimators(plan.definition.animators || []);
|
|
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
|
|
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
|
|
Canvas.updateAll();
|
|
applyAllActorTexturePreviews(false);
|
|
refreshBlockGuideFaces();
|
|
const finished = { ...aspects, elements: Outliner.elements.slice(), groups: Group.all.slice(),
|
|
textures: Texture.all.slice(), animations: Animation.all.slice() };
|
|
Undo.finishEdit('Automatically import animation', finished);
|
|
Project.saved = false;
|
|
if (Animation.all[0]) Animation.all[0].select();
|
|
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(plan.parsed.animationId);
|
|
Blockbench.showQuickMessage(`Imported ${plan.parsed.animationId}: ${plan.rows.length} actors, ${plan.imported.stageNames.length} stages.`, 4000);
|
|
} catch (error) {
|
|
Undo.cancelEdit();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function automaticallyImportAnimation() {
|
|
try {
|
|
const io = desktopFiles();
|
|
const file = await pickImportFile('Automatically Import Animation (definition or animation file)');
|
|
if (!file) return;
|
|
const source = findImportPack(file.path, io);
|
|
const companions = companionImportFiles(source, file.path, io);
|
|
if (!/^[a-z0-9._-]+$/.test(companions.id)) {
|
|
throw new Error('This editor requires an animation ID without subfolders. Nested <ID>/<ID>.animation.json files are supported.');
|
|
}
|
|
const defFile = await chooseCompanion(companions.definitions, 'Choose Animation Definition');
|
|
if (!defFile) return;
|
|
const animFile = await chooseCompanion(companions.animations, 'Choose Animation File');
|
|
if (!animFile) return;
|
|
const definition = readImportJson(defFile, io);
|
|
const parsed = parseConjoinedAnimationFile({ name: io.path.basename(animFile), path: animFile,
|
|
content: JSON.stringify(readImportJson(animFile, io)) });
|
|
if (parsed.animationId !== companions.id.split('/').pop()) throw new Error('The companion animation filename must use the same animation ID.');
|
|
parsed.animationId = companions.id;
|
|
if (!Array.isArray(definition.actors) || !definition.actors.length) {
|
|
throw new Error('The definition must contain at least one actor.');
|
|
}
|
|
const registry = importPackRegistry(source, savedPackFolders(), io);
|
|
const keys = definitionActorKeys(definition.actors);
|
|
const clipRoles = defaultImportClipRoles(definition.actors, parsed.actorLabels);
|
|
const usedClips = new Set(clipRoles.filter(Boolean));
|
|
clipRoles.forEach((label, index) => {
|
|
if (label) return;
|
|
const available = parsed.actorLabels.find((candidate) => !usedClips.has(candidate));
|
|
if (available) { clipRoles[index] = available; usedClips.add(available); }
|
|
});
|
|
const form = { info: { type: 'info', text: `### ${companions.id}\nImport into a NEW GeckoLib project. Choose concrete actors and confirm their clip roles. Use Manual Import > Pack Folders to register shared/default packs before importing.\nFolder priority: ${registry.packs.map((pack) => pack.name).join(' > ')}` } };
|
|
definition.actors.forEach((actor, index) => {
|
|
const entities = (actor.entity_types || []).filter((id) => importResourceId(id));
|
|
form[`heading_${index}`] = { type: 'info', text: `### Actor ${index + 1}: ${keys[index]}` };
|
|
form[`clip_${index}`] = { type: 'select', label: 'Animation actor', value: clipRoles[index],
|
|
options: { '': 'Choose actor...', ...Object.fromEntries(parsed.actorLabels.map((label) => [label, label])) } };
|
|
form[`entity_${index}`] = entities.length ? { type: 'select', label: 'Entity', value: entities[0],
|
|
options: Object.fromEntries(entities.map((id) => [id, id])) }
|
|
: { type: 'text', label: 'Concrete entity ID', value: '', placeholder: 'minecraft:player' };
|
|
const required = actor.actor_tags || [];
|
|
const genders = {};
|
|
if (!required.includes('gender.female')) genders.m = 'Male';
|
|
if (!required.includes('gender.male')) genders.f = 'Female';
|
|
genders.mf = 'Male + female';
|
|
form[`gender_${index}`] = { type: 'select', label: 'Model gender', value: inferImportGender(actor) || Object.keys(genders)[0], options: genders };
|
|
if (!entities.length || entities.includes('minecraft:player')) form[`slim_${index}`] = { type: 'checkbox', label: 'Slim player model', value: false,
|
|
condition: (result) => result[`entity_${index}`] === 'minecraft:player' };
|
|
});
|
|
const choices = await importDialog('Automatic Import: Actors', form);
|
|
if (!choices) return;
|
|
const rows = definition.actors.map((actor, index) => ({ actor, label: choices[`clip_${index}`], entity: choices[`entity_${index}`], gender: choices[`gender_${index}`], slim: !!choices[`slim_${index}`] }));
|
|
if (new Set(rows.map((row) => row.label)).size !== rows.length || rows.some((row) => !parsed.actorLabels.includes(row.label))) {
|
|
throw new Error('Each definition actor must be assigned a distinct animation actor.');
|
|
}
|
|
const selectedLabels = new Set(rows.map((row) => row.label));
|
|
const ignoredLabels = parsed.actorLabels.filter((label) => !selectedLabels.has(label));
|
|
// The definition owns the actor set; discard all tracks and effects belonging to unused clips.
|
|
parsed.actorLabels = parsed.actorLabels.filter((label) => selectedLabels.has(label));
|
|
parsed.stages = parsed.stages.map((stage) => ({ ...stage,
|
|
entries: stage.entries.filter((entry) => selectedLabels.has(entry.actorLabel))
|
|
})).filter((stage) => stage.entries.length);
|
|
parsed.stages = resolveDefinitionImportStages(parsed, definition);
|
|
// Preserve role references while relabeling the definition for the selected asset clips.
|
|
const relabeled = clone(definition);
|
|
relabeled.actors.forEach((actor, index) => { actor.label ||= keys[index]; });
|
|
const analysis = { actorIndices: rows.map((_, index) => index + 1), actorLabels: new Map(rows.map((row, index) => [index + 1, row.label])),
|
|
stages: parsed.stages.map((stage) => ({ stageNumber: stage.stageNumber })) };
|
|
const labelMap = new Map(relabeled.actors.map((actor, index) => [actor.label, rows[index].label]));
|
|
relabeled.actors.forEach((actor, index) => {
|
|
actor.label = rows[index].label;
|
|
if (actor.injects_into !== undefined) {
|
|
const targets = Array.isArray(actor.injects_into) ? actor.injects_into : [actor.injects_into];
|
|
actor.injects_into = targets.map((target) => {
|
|
if (!labelMap.has(target)) throw new Error(`Unknown injection target: ${target}`);
|
|
return labelMap.get(target);
|
|
});
|
|
}
|
|
});
|
|
for (const stage of relabeled.stages || []) {
|
|
if (stage.props && typeof stage.props === 'object' && !Array.isArray(stage.props)) {
|
|
stage.props = Object.fromEntries(Object.entries(stage.props).map(([key, value]) => [labelMap.get(key) || key, value]));
|
|
}
|
|
}
|
|
if (relabeled.position_anchor_actor !== undefined) relabeled.position_anchor_actor = labelMap.get(relabeled.position_anchor_actor) || relabeled.position_anchor_actor;
|
|
const prepared = prepareAnimationDefinitionImport(relabeled, analysis);
|
|
const warnings = registry.warnings.slice();
|
|
if (ignoredLabels.length) warnings.push(`Ignored animation actors not mapped to the definition: ${ignoredLabels.join(', ')}.`);
|
|
const models = { info: { type: 'info', text: '### Actor models\nModels follow local, declared imports, old default revisions, and shared fallbacks. Choose a replacement only when necessary; missing models must be selected manually.' } };
|
|
rows.forEach((row, index) => {
|
|
row.resolved = resolveImportModel(source, registry.packs, row.entity, row.gender, row.actor.entity_variant, io, row.slim);
|
|
models[`source_${index}`] = { type: 'info', text: `### ${row.label}\n${row.resolved ? `${row.resolved.kind}: ${row.resolved.file}` : 'Model not found. Select a geometry file below.'}` };
|
|
models[`model_${index}`] = { type: 'file', label: row.resolved ? 'Override model (optional)' : 'Select model (required)',
|
|
return_as: 'file', extensions: ['json'], readtype: 'text', filetype: 'GeckoLib geometry' };
|
|
});
|
|
const modelChoices = await importDialog('Automatic Import: Models', models);
|
|
if (!modelChoices) return;
|
|
for (const [index, row] of rows.entries()) {
|
|
if (modelChoices[`model_${index}`]?.path) row.resolved = { file: modelChoices[`model_${index}`].path, kind: 'Manually selected' };
|
|
if (!row.resolved) throw new Error(`Select a model for ${row.label}.`);
|
|
row.json = readImportJson(row.resolved.file, io);
|
|
row.geo = validateImportGeometry(row.json);
|
|
loadImportTextures(row, registry.packs, io);
|
|
const names = new Set(row.geo.bones.map((bone) => bone.name));
|
|
const missing = new Set(parsed.stages.flatMap((stage) => stage.entries.filter((entry) => entry.actorLabel === row.label)
|
|
.flatMap((entry) => Object.keys(entry.animObj.bones || {}).filter((bone) => !names.has(bone)))));
|
|
if (missing.size) warnings.push(`${row.label}: model lacks animated bones: ${[...missing].join(', ')}. Tracks will be retained.`);
|
|
if (!row.textures.has(row.defaultTexture)) warnings.push(`${row.label}: no base texture found locally. Set its preview texture using Actor Preferences.`);
|
|
}
|
|
const imported = buildConjoinedImportFile(parsed, parsed.animationId, new Map(rows.map((row, index) => [row.label, index + 1])));
|
|
let blockEntry = null;
|
|
if (prepared.block_requirements) {
|
|
const fields = buildBlockRequirementForm(parsed.animationId, prepared.block_requirements);
|
|
blockEntry = readBlockRequirementForm(Object.fromEntries(Object.entries(fields).filter(([, value]) => value.type !== 'info').map(([key, value]) => [key, value.value])));
|
|
buildBlockRequirementGuideBoxes(blockEntry);
|
|
}
|
|
const review = { info: { type: 'info', text: `### Import ${parsed.animationId}\n${rows.map((row) => `${row.label}: ${row.resolved.kind}\n${row.resolved.file}`).join('\n\n')}\n\n${warnings.join('\n')}` } };
|
|
const accepted = await importDialog('Automatic Import: Review', review, 'Create Project and Import');
|
|
if (!accepted) return;
|
|
commitAutomaticImport({ parsed, rows, definition: prepared, imported, blockEntry });
|
|
localStorage.setItem(PACK_FOLDERS_KEY, JSON.stringify(registry.packs.map((pack) => pack.root)));
|
|
} catch (error) {
|
|
Blockbench.showMessageBox({ title: 'Automatically Import Animation', message: error.message, buttons: ['OK'] });
|
|
}
|
|
}
|
|
|
|
Plugin.register(PLUGIN_ID, {
|
|
title: 'AnimationDirector',
|
|
author: 'L1Z0',
|
|
icon: 'groups',
|
|
description: 'Multi-actor animation in Blockbench without bone-name clashes.',
|
|
about: `AnimationDirector is a Blockbench plugin for the mod of the same name.
|
|
|
|
It lets you import and animate multiple actors in one project without Blockbench auto-renaming bones when different models use the same bone names (like head, body, etc.). It does this by organizing each actor with an actorN_ prefix, so everything stays consistent.
|
|
|
|
You can import or export complete AFW animation IDs as one file containing all stages and actors, or generate a configurable AFW animation definition from the current project.`,
|
|
version: '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: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[BLOCK_REQUIREMENTS_PROPERTY]) {
|
|
blockRequirementsProjectProperty = new Property(ModelProject, 'string', BLOCK_REQUIREMENTS_PROPERTY, {
|
|
default: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[PROP_PREVIEWS_PROPERTY]) {
|
|
propProjectProperty = new Property(ModelProject, 'string', PROP_PREVIEWS_PROPERTY, { default: '{}' });
|
|
}
|
|
}
|
|
|
|
blockGuidePreviewListener = ({ element }) => applyBlockRequirementGuideMaterial(element);
|
|
Cube.preview_controller.on('setup update_faces', blockGuidePreviewListener);
|
|
for (const cube of Cube.all) applyBlockRequirementGuideMaterial(cube);
|
|
blockGuideProjectListener = refreshBlockGuideFaces;
|
|
Blockbench.on('select_project', blockGuideProjectListener);
|
|
refreshBlockGuideFaces();
|
|
blockRequirementUndoSaveListener = ({ save, aspects }) => {
|
|
if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}';
|
|
if (aspects.animdef_edit) save[ANIMDEF_DEFINITIONS_PROPERTY] = Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}';
|
|
if (aspects.prop_preview_edit) save[PROP_PREVIEWS_PROPERTY] = Project[PROP_PREVIEWS_PROPERTY] || '{}';
|
|
if (aspects.automatic_animation_import) {
|
|
for (const property of IMPORT_METADATA) save[property] = Project[property] ?? (property === ANIMDEF_ANIMATORS_PROPERTY ? '' : '{}');
|
|
}
|
|
};
|
|
blockRequirementUndoLoadListener = ({ save }) => {
|
|
if (save[BLOCK_REQUIREMENTS_PROPERTY] !== undefined) Project[BLOCK_REQUIREMENTS_PROPERTY] = save[BLOCK_REQUIREMENTS_PROPERTY];
|
|
if (save[ANIMDEF_DEFINITIONS_PROPERTY] !== undefined) Project[ANIMDEF_DEFINITIONS_PROPERTY] = save[ANIMDEF_DEFINITIONS_PROPERTY];
|
|
for (const property of IMPORT_METADATA) {
|
|
if (save[property] !== undefined) Project[property] = save[property];
|
|
}
|
|
scheduleActorTexturePreviewApply();
|
|
void refreshPropPreviews(true);
|
|
};
|
|
Blockbench.on('create_undo_save', blockRequirementUndoSaveListener);
|
|
Blockbench.on('load_undo_save', blockRequirementUndoLoadListener);
|
|
propListener = () => { void refreshPropPreviews().catch((error) => {
|
|
console.warn('Could not refresh item prop preview:', error);
|
|
}); };
|
|
Blockbench.on('select_animation select_project load_project unselect_project undo redo render_frame', propListener);
|
|
|
|
propAction = new Action('animationdirector_item_props', {
|
|
name: 'Item Props', icon: 'interests', description: 'Preview item props on actor bones and configure default/per-stage animdef props.',
|
|
click: showPropDialog,
|
|
});
|
|
|
|
blockRequirementAction = new Action('animationdirector_add_block_requirement', {
|
|
name: 'Block Requirements',
|
|
description: 'Open the live block requirement panel with blue structure / red clearance guides.',
|
|
icon: 'view_in_ar',
|
|
click: addBlockRequirement,
|
|
});
|
|
|
|
importAction = new Action('import_actor_gecko_json', {
|
|
name: 'Import Actor',
|
|
description: 'Import a GeckoLib/Bedrock geometry JSON into the current project and prefix bones with actorN_.',
|
|
icon: 'person_add',
|
|
click: importActorGeckoJson,
|
|
});
|
|
|
|
replaceActorAction = new Action('replace_actor_gecko_json', {
|
|
name: 'Replace Actor Model',
|
|
description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.',
|
|
icon: 'published_with_changes',
|
|
click: replaceActorGeckoJson,
|
|
});
|
|
|
|
importConjoinedAnimationAction = new Action('import_conjoined_actor_animation', {
|
|
name: 'Import Animation',
|
|
description: 'Import one conjoined AnimationDirector JSON and rebuild every stage and actor animation.',
|
|
icon: 'playlist_add',
|
|
click: importConjoinedAnimation,
|
|
});
|
|
|
|
actorPreferencesAction = new Action('edit_actor_preferences', {
|
|
name: 'Actor Preferences',
|
|
description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.',
|
|
icon: '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,
|
|
});
|
|
|
|
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,
|
|
});
|
|
importAnimationDefinitionAction = new Action('import_afw_animation_definition', {
|
|
name: 'Import Animation Definition',
|
|
description: 'Load generator fields and block requirement guides from an existing animdef JSON.',
|
|
icon: 'file_open',
|
|
click: importAnimationDefinition,
|
|
});
|
|
automaticImportAction = new Action('animationdirector_automatic_import', {
|
|
name: 'Automatically Import Animation', icon: 'auto_awesome',
|
|
description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.',
|
|
click: automaticallyImportAnimation,
|
|
});
|
|
packFoldersAction = new Action('animationdirector_pack_folders', {
|
|
name: 'Pack Folders', icon: 'folder',
|
|
description: 'Remember extracted packs and their model/texture lookup priority for automatic import.',
|
|
click() {
|
|
try { editImportPackFolders(); }
|
|
catch (error) { Blockbench.showMessageBox({ title: 'Pack Folders', message: error.message, buttons: ['OK'] }); }
|
|
},
|
|
});
|
|
|
|
MenuBar.menus.tools.addAction({
|
|
id: 'animationdirector_model',
|
|
name: 'AnimationDirector Model',
|
|
icon: 'deployed_code',
|
|
children: [boneTextureOverridesAction, exportGeckoModelAction],
|
|
});
|
|
|
|
MenuBar.menus.tools.addAction({
|
|
id: 'animationdirector_anims',
|
|
name: 'AnimationDirector Anims',
|
|
icon: 'movie',
|
|
children: [
|
|
automaticImportAction,
|
|
{ id: 'animationdirector_manual_import', name: 'Manual Import', icon: 'file_open', children: [
|
|
importAction, importConjoinedAnimationAction, importAnimationDefinitionAction, packFoldersAction,
|
|
] },
|
|
actorPreferencesAction,
|
|
replaceActorAction,
|
|
exportConjoinedAction,
|
|
blockRequirementAction,
|
|
propAction,
|
|
createAnimationDefinitionAction,
|
|
],
|
|
});
|
|
|
|
actorTexturePreviewProjectListener = scheduleActorTexturePreviewApply;
|
|
for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
|
|
Blockbench.on(eventName, actorTexturePreviewProjectListener);
|
|
}
|
|
scheduleActorTexturePreviewApply();
|
|
|
|
MenuBar.update();
|
|
},
|
|
onunload() {
|
|
clearPropMeshes(); propSources.clear();
|
|
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete();
|
|
if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener);
|
|
propDialog = undefined; propListener = undefined;
|
|
clearScheduledActorTexturePreviewApplies();
|
|
if (actorTexturePreviewProjectListener) {
|
|
for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
|
|
Blockbench.removeListener(eventName, actorTexturePreviewProjectListener);
|
|
}
|
|
actorTexturePreviewProjectListener = null;
|
|
}
|
|
MenuBar.menus.tools.removeAction('animationdirector_model');
|
|
MenuBar.menus.tools.removeAction('animationdirector_anims');
|
|
importAction.delete();
|
|
importConjoinedAnimationAction.delete();
|
|
actorPreferencesAction.delete();
|
|
boneTextureOverridesAction.delete();
|
|
exportGeckoModelAction.delete();
|
|
replaceActorAction.delete();
|
|
exportConjoinedAction.delete();
|
|
createAnimationDefinitionAction.delete();
|
|
importAnimationDefinitionAction.delete();
|
|
automaticImportAction.delete();
|
|
packFoldersAction.delete();
|
|
blockRequirementAction.delete();
|
|
Blockbench.removeListener('select_project', blockGuideProjectListener);
|
|
cancelBlockRequirementUpdate();
|
|
if (blockRequirementPanelListener) {
|
|
for (const event of ['select_project', 'unselect_project', 'undo', 'redo']) {
|
|
Blockbench.removeListener(event, blockRequirementPanelListener);
|
|
}
|
|
}
|
|
blockRequirementPanel?.delete();
|
|
blockRequirementPanelCss?.delete();
|
|
blockRequirementPanel = undefined;
|
|
blockRequirementPanelListener = undefined;
|
|
blockRequirementPanelCss = undefined;
|
|
Cube.preview_controller.removeListener('setup', blockGuidePreviewListener);
|
|
Cube.preview_controller.removeListener('update_faces', blockGuidePreviewListener);
|
|
Blockbench.removeListener('create_undo_save', blockRequirementUndoSaveListener);
|
|
Blockbench.removeListener('load_undo_save', blockRequirementUndoLoadListener);
|
|
Canvas.updateAllFaces();
|
|
for (const material of blockGuideMaterials.values()) material.dispose();
|
|
blockGuideMaterials.clear();
|
|
if (actorNamesProjectProperty?.delete) actorNamesProjectProperty.delete();
|
|
if (actorTexturesProjectProperty?.delete) actorTexturesProjectProperty.delete();
|
|
if (boneTextureOverridesProjectProperty?.delete) boneTextureOverridesProjectProperty.delete();
|
|
if (actorAnimdefMetadataProjectProperty?.delete) actorAnimdefMetadataProjectProperty.delete();
|
|
if (animdefAnimatorsProjectProperty?.delete) animdefAnimatorsProjectProperty.delete();
|
|
if (animdefDefinitionsProjectProperty?.delete) animdefDefinitionsProjectProperty.delete();
|
|
if (blockRequirementsProjectProperty?.delete) blockRequirementsProjectProperty.delete();
|
|
},
|
|
});
|
|
})();
|