7302 lines
352 KiB
JavaScript
7302 lines
352 KiB
JavaScript
(function () {
|
|
const PLUGIN_ID = 'AnimationDirector';
|
|
const ACTOR_NAMES_PROPERTY = 'multiactor_actor_names';
|
|
const ACTOR_TEXTURES_PROPERTY = 'multiactor_actor_textures';
|
|
const ACTOR_UV_BASIS_PROPERTY = 'animationdirector_actor_uv_basis';
|
|
const BONE_TEXTURE_OVERRIDES_PROPERTY = 'multiactor_bone_texture_overrides';
|
|
const ACTOR_ANIMDEF_METADATA_PROPERTY = 'multiactor_actor_animdef_metadata';
|
|
const ANIMDEF_ANIMATORS_PROPERTY = 'multiactor_animdef_animators';
|
|
const ANIMDEF_DEFINITIONS_PROPERTY = 'animationdirector_animdef_definitions';
|
|
const BLOCK_REQUIREMENTS_PROPERTY = 'animationdirector_block_requirements';
|
|
const BLOCK_GUIDE_PREFIX = '__ad_block_requirement_';
|
|
const BLOCK_GUIDE_COLORS = { required: 0x489dff, clearance: 0xff5555 };
|
|
const blockGuideMaterials = new Map();
|
|
let blockRequirementAction, blockRequirementsProjectProperty, blockGuidePreviewListener;
|
|
let blockRequirementUndoSaveListener, blockRequirementUndoLoadListener;
|
|
let blockRequirementPanel, blockRequirementPanelListener, blockRequirementPanelCss;
|
|
let blockRequirementPendingTimer;
|
|
let blockGuideProjectListener;
|
|
let importAnimationDefinitionAction;
|
|
let automaticImportAction;
|
|
let packFoldersAction;
|
|
let createJemModelAction;
|
|
let previousStagePoseAction;
|
|
let loopPoseAction;
|
|
let autoplayStagesAction, autoplayLoopOriginal, autoplayLoopWrapper;
|
|
let autoplayTransitioning = false;
|
|
const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'finish_edit', 'add_animation', 'remove_animation'];
|
|
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
|
|
const MODEL_VARIANTS_PROPERTY = 'animationdirector_model_variants';
|
|
const JEM_VARIANTS_PROPERTY = 'animationdirector_jem_variants';
|
|
let modelVariantsProjectProperty;
|
|
let jemVariantsProjectProperty;
|
|
let jemExportActionListener;
|
|
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
|
|
let propAction, propProjectProperty, propDialog, propListener;
|
|
const propMeshes = new Map();
|
|
const propSources = new Map();
|
|
let propPreviewSignature = null, propPreviewGeneration = 0;
|
|
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
|
|
const SOUND_SELECTION_BLACKLIST = new Set(['needsofnature:a', 'needsofnature:funkytown']);
|
|
const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview';
|
|
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection', 'undo', 'redo', 'finish_edit'];
|
|
let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition;
|
|
let soundLibrary, soundLibraryGeneration = 0;
|
|
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
|
|
let soundAudioBytes = 0;
|
|
const PARTICLE_SOURCE_KEY = 'animationdirector_particle_source';
|
|
const NON_PARTICLE_IDS = ['liquid_particle', 'liquid_particle_falling', 'liquid_particle_puddle',
|
|
'liquid_particle_water', 'smallheart', 'ripped_fabric', 'spray_splash', 'spray_surface_drip',
|
|
'spray_surface_viscous', 'spray_surface_liquid'].map((name) => `needsofnature:${name}`);
|
|
let particleLibraryAction, particleLibraryDialog, particleLibrary;
|
|
const PARTICLE_PREVIEW_PROPERTY = 'animationdirector_particle_preview';
|
|
const PARTICLE_PREVIEW_PREFIX = 'animationdirector-particle:';
|
|
const PARTICLE_PREVIEWS_KEY = 'animationdirector_particle_previews';
|
|
const PARTICLE_PREVIEW_KINDS = { 'needsofnature:liquid_particle': 'liquid', 'needsofnature:liquid_particle_falling': 'falling',
|
|
'needsofnature:smallheart': 'heart', 'needsofnature:ripped_fabric': 'fabric' };
|
|
let particlePreviewProperty, particlePreviewToggle, particlePreviewsEnabled = true;
|
|
const particlePreviewEffects = new Map(), particlePreviewPoints = new Set();
|
|
const figuraSoundPatches = [], figuraSoundMedia = new Set();
|
|
let figuraSoundCompatEnabled = true, figuraSoundPlayListener;
|
|
const SYSTEM_TAG_OPTIONS = {
|
|
'': 'None (normal animation)',
|
|
manualpeak: 'Manual peak',
|
|
defeated: 'Defeated',
|
|
stuck: 'Stuck',
|
|
tease: 'Tease',
|
|
birth: 'Birth',
|
|
fillbottle: 'Fill bottle',
|
|
other: 'Other',
|
|
};
|
|
|
|
let importAction, replaceActorAction, importConjoinedAnimationAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction;
|
|
let actorNamesProjectProperty, actorTexturesProjectProperty, actorUvBasisProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty;
|
|
let actorTexturePreviewProjectListener;
|
|
const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path'];
|
|
let actorTexturePreviewTimeouts = [];
|
|
|
|
// ----------------------------
|
|
// Helpers
|
|
// ----------------------------
|
|
const num = (n, fallback = 0) => {
|
|
const v = Number(n);
|
|
return Number.isFinite(v) ? v : fallback;
|
|
};
|
|
|
|
const v3 = (arr, fallback = 0) =>
|
|
Array.isArray(arr) ? [num(arr[0], fallback), num(arr[1], fallback), num(arr[2], fallback)] : [fallback, fallback, fallback];
|
|
|
|
const sanitizeFilePart = (name) => {
|
|
let s = String(name || 'animation').trim();
|
|
|
|
// Windows-invalid filename chars + backslash without using escapes
|
|
const backslash = String.fromCharCode(92);
|
|
const bad = new Set([backslash, '/', ':', '*', '?', '"', '<', '>', '|']);
|
|
|
|
let out = '';
|
|
for (const ch of s) out += bad.has(ch) ? '_' : ch;
|
|
|
|
out = out.trim();
|
|
while (out.indexOf(' ') !== -1) out = out.split(' ').join(' ');
|
|
return out;
|
|
};
|
|
|
|
const clone = (obj) => JSON.parse(JSON.stringify(obj));
|
|
|
|
function formatAnimationDisplayName(animationId) {
|
|
const path = String(animationId || '').split(':').pop();
|
|
return path.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
}
|
|
|
|
function getProjectStringMap(propertyName) {
|
|
if (!Project) return {};
|
|
|
|
const raw = Project[propertyName];
|
|
if (typeof raw !== 'string' || !raw.trim()) return {};
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
|
|
const map = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
const idx = parseInt(key, 10);
|
|
const text = String(value || '').trim();
|
|
if (Number.isFinite(idx) && idx > 0 && text) map[String(idx)] = text;
|
|
}
|
|
return map;
|
|
} catch (e) {
|
|
console.warn(`Could not parse saved project map "${propertyName}":`, e);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function setProjectStringMap(propertyName, values) {
|
|
if (!Project) return;
|
|
|
|
const clean = {};
|
|
for (const [key, value] of Object.entries(values || {})) {
|
|
const idx = parseInt(key, 10);
|
|
const text = String(value || '').trim();
|
|
if (Number.isFinite(idx) && idx > 0 && text) clean[String(idx)] = text;
|
|
}
|
|
|
|
Project[propertyName] = JSON.stringify(clean);
|
|
Project.saved = false;
|
|
}
|
|
|
|
function getProjectActorNames() {
|
|
return getProjectStringMap(ACTOR_NAMES_PROPERTY);
|
|
}
|
|
|
|
function setProjectActorNames(names) {
|
|
setProjectStringMap(ACTOR_NAMES_PROPERTY, names);
|
|
}
|
|
|
|
function remapActorReferences(previousNames, nextNames, indices, metadata) {
|
|
const labels = new Map(indices.map((index) => [previousNames[index] || `actor${index}`,
|
|
nextNames[index] || `actor${index}`]));
|
|
const nextLabels = [...labels.values()];
|
|
if (new Set(nextLabels).size !== nextLabels.length) throw new Error('Actor export names must be unique.');
|
|
const rename = (label) => labels.get(label) ?? label;
|
|
const targets = (value) => Array.isArray(value) ? value.map(rename) : typeof value === 'string' ? rename(value) : value;
|
|
for (const entry of Object.values(metadata)) {
|
|
if (entry.injects_into) entry.injects_into = targets(entry.injects_into);
|
|
}
|
|
const definitions = JSON.parse(Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}');
|
|
for (const definition of Object.values(definitions)) {
|
|
for (const actor of definition.actors || []) {
|
|
actor.label = rename(actor.label);
|
|
if (actor.injects_into) actor.injects_into = targets(actor.injects_into);
|
|
}
|
|
if (definition.position_anchor_actor) definition.position_anchor_actor = rename(definition.position_anchor_actor);
|
|
for (const stage of definition.stages || []) {
|
|
if (stage.props && typeof stage.props === 'object') {
|
|
stage.props = Object.fromEntries(Object.entries(stage.props).map(([label, value]) => [rename(label), value]));
|
|
}
|
|
}
|
|
}
|
|
const previews = Object.fromEntries(Object.values(readPropPreviews()).map((entry) => {
|
|
const updated = { ...entry, actor: rename(entry.actor) };
|
|
return [propEntryKey(updated.animation, updated.actor, updated.slot, updated.stage), updated];
|
|
}));
|
|
Project[ANIMDEF_DEFINITIONS_PROPERTY] = JSON.stringify(definitions);
|
|
Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(previews);
|
|
}
|
|
|
|
function getProjectActorTextures() {
|
|
return getProjectStringMap(ACTOR_TEXTURES_PROPERTY);
|
|
}
|
|
|
|
function setProjectActorTextures(textures) {
|
|
setProjectStringMap(ACTOR_TEXTURES_PROPERTY, textures);
|
|
}
|
|
|
|
function getActorUvBasis() {
|
|
try { return JSON.parse(Project?.[ACTOR_UV_BASIS_PROPERTY] || '{}'); } catch (_) { return {}; }
|
|
}
|
|
|
|
function setActorUvBasis(actorIndex, width, height) {
|
|
const all = getActorUvBasis();
|
|
if (all[actorIndex]?.width === width && all[actorIndex]?.height === height) return;
|
|
all[actorIndex] = { ...all[actorIndex], width, height };
|
|
Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all);
|
|
Project.saved = false;
|
|
}
|
|
|
|
function uniqueTrimmedStrings(values) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const value of Array.isArray(values) ? values : []) {
|
|
const text = String(value || '').trim();
|
|
if (!text || seen.has(text)) continue;
|
|
seen.add(text);
|
|
out.push(text);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseCommaSeparatedStrings(value) {
|
|
return uniqueTrimmedStrings(String(value || '').split(','));
|
|
}
|
|
|
|
function normalizeAnimatorNames(values) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const value of Array.isArray(values) ? values : []) {
|
|
const text = String(value || '').trim();
|
|
const key = text.toLocaleLowerCase();
|
|
if (!text || seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(text);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function getProjectAnimdefAnimators() {
|
|
if (!Project) return [];
|
|
return normalizeAnimatorNames(String(Project[ANIMDEF_ANIMATORS_PROPERTY] || '').split(','));
|
|
}
|
|
|
|
function setProjectAnimdefAnimators(animators) {
|
|
if (!Project) return;
|
|
Project[ANIMDEF_ANIMATORS_PROPERTY] = normalizeAnimatorNames(animators).join(', ');
|
|
Project.saved = false;
|
|
}
|
|
|
|
function getSavedAnimdef(groupId) {
|
|
if (!Project) return {};
|
|
try {
|
|
const saved = JSON.parse(String(Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}'));
|
|
const definition = saved?.[groupId];
|
|
return definition && typeof definition === 'object' && !Array.isArray(definition) ? definition : {};
|
|
} catch (e) {
|
|
console.warn('Could not read saved animation definitions:', e);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveAnimdef(groupId, definition) {
|
|
if (!Project) return;
|
|
let saved;
|
|
try {
|
|
saved = JSON.parse(String(Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}'));
|
|
} catch (e) {
|
|
saved = {};
|
|
}
|
|
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) saved = {};
|
|
saved[groupId] = definition;
|
|
Project[ANIMDEF_DEFINITIONS_PROPERTY] = JSON.stringify(saved);
|
|
Project.saved = false;
|
|
}
|
|
|
|
function cleanActorAnimdefMetadata(value) {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
const out = {};
|
|
const entityTypes = uniqueTrimmedStrings(source.entity_types);
|
|
const actorTags = uniqueTrimmedStrings(source.actor_tags);
|
|
const actorTagsAny = uniqueTrimmedStrings(source.actor_tags_any);
|
|
const entityVariant = String(source.entity_variant || '').trim();
|
|
const activity = String(source.activity || '').trim().toLowerCase();
|
|
const injector = String(source.injector || '').trim().toUpperCase();
|
|
const injectsInto = uniqueTrimmedStrings(source.injects_into);
|
|
const heldItems = uniqueTrimmedStrings(source.held_item?.items);
|
|
const heldHand = String(source.held_item?.hand || 'main').trim().toLowerCase();
|
|
const heldPropSlot = String(source.held_item?.prop_slot || '').trim().toLowerCase();
|
|
|
|
if (entityTypes.length) out.entity_types = entityTypes;
|
|
if (entityVariant) out.entity_variant = entityVariant;
|
|
if (actorTags.length) out.actor_tags = actorTags;
|
|
if (actorTagsAny.length) out.actor_tags_any = actorTagsAny;
|
|
if (activity === 'active' || activity === 'passive') out.activity = activity;
|
|
if (injector === 'V' || injector === 'A' || injector === 'M') out.injector = injector;
|
|
if (source.receiver === true) out.receiver = true;
|
|
if (injectsInto.length) out.injects_into = injectsInto;
|
|
if (heldItems.length) {
|
|
out.held_item = { items: heldItems, hand: heldHand };
|
|
if (heldPropSlot) out.held_item.prop_slot = heldPropSlot;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function getProjectActorAnimdefMetadata() {
|
|
if (!Project) return {};
|
|
|
|
const raw = Project[ACTOR_ANIMDEF_METADATA_PROPERTY];
|
|
if ((typeof raw !== 'string' || !raw.trim()) && (!raw || typeof raw !== 'object')) return {};
|
|
|
|
try {
|
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
|
|
const map = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
const idx = parseInt(key, 10);
|
|
if (!Number.isFinite(idx) || idx <= 0) continue;
|
|
map[String(idx)] = cleanActorAnimdefMetadata(value);
|
|
}
|
|
return map;
|
|
} catch (e) {
|
|
console.warn(`Could not parse saved project map "${ACTOR_ANIMDEF_METADATA_PROPERTY}":`, e);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function setProjectActorAnimdefMetadata(metadata) {
|
|
if (!Project) return;
|
|
|
|
const clean = {};
|
|
for (const [key, value] of Object.entries(metadata || {})) {
|
|
const idx = parseInt(key, 10);
|
|
if (!Number.isFinite(idx) || idx <= 0) continue;
|
|
const entry = cleanActorAnimdefMetadata(value);
|
|
if (Object.keys(entry).length) clean[String(idx)] = entry;
|
|
}
|
|
|
|
Project[ACTOR_ANIMDEF_METADATA_PROPERTY] = JSON.stringify(clean);
|
|
Project.saved = false;
|
|
}
|
|
|
|
function getProjectBoneTextureOverrides() {
|
|
if (!Project) return {};
|
|
|
|
const raw = Project[BONE_TEXTURE_OVERRIDES_PROPERTY];
|
|
if ((typeof raw !== 'string' || !raw.trim()) && (!raw || typeof raw !== 'object')) return {};
|
|
|
|
try {
|
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
|
|
const map = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
|
|
const uuid = String(value.uuid || key || '').trim();
|
|
const name = String(value.name || '').trim();
|
|
const texture = String(value.texture || '').trim();
|
|
const mapKey = uuid || name;
|
|
if (mapKey && texture) map[mapKey] = { uuid, name, texture };
|
|
}
|
|
return map;
|
|
} catch (e) {
|
|
console.warn(`Could not parse saved project map "${BONE_TEXTURE_OVERRIDES_PROPERTY}":`, e);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function setProjectBoneTextureOverrides(overrides) {
|
|
if (!Project) return;
|
|
|
|
const clean = {};
|
|
for (const value of Object.values(overrides || {})) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
|
|
const uuid = String(value.uuid || '').trim();
|
|
const name = String(value.name || '').trim();
|
|
const texture = String(value.texture || '').trim();
|
|
const key = uuid || name;
|
|
if (key && texture) clean[key] = { uuid, name, texture };
|
|
}
|
|
|
|
Project[BONE_TEXTURE_OVERRIDES_PROPERTY] = JSON.stringify(clean);
|
|
Project.saved = false;
|
|
}
|
|
|
|
function getActorExportLabel(actorIndex) {
|
|
const names = getProjectActorNames();
|
|
const saved = String(names[String(actorIndex)] || '').trim();
|
|
return saved || `actor${actorIndex}`;
|
|
}
|
|
|
|
function getActorSelectLabel(actorIndex) {
|
|
const label = getActorExportLabel(actorIndex);
|
|
const fallback = `actor${actorIndex}`;
|
|
return label === fallback ? fallback : `${fallback} (${label})`;
|
|
}
|
|
|
|
function buildActorSelectOptions() {
|
|
const indices = getSortedUsedActorIndicesFromProject();
|
|
const options = {};
|
|
for (const idx of indices) options[String(idx)] = getActorSelectLabel(idx);
|
|
return options;
|
|
}
|
|
|
|
const GENERIC_ACTOR_LABELS = new Set(['actor', 'top', 'bottom', 'anchor', 'root', 'entity', 'mob']);
|
|
const RESOURCE_ID_PATTERN = /^[a-z0-9_.-]+:[a-z0-9/._-]+$/;
|
|
|
|
function inferEntityTypeFromActorLabel(label) {
|
|
let candidate = String(label || '').trim().toLowerCase();
|
|
candidate = candidate.replace(/[_-]?\d+$/, '');
|
|
if (!candidate || GENERIC_ACTOR_LABELS.has(candidate)) return '';
|
|
if (!/^[a-z0-9_]+$/.test(candidate)) return '';
|
|
return `minecraft:${candidate}`;
|
|
}
|
|
|
|
function actorAnimdefMetadataWithInference(actorIndex, metadata) {
|
|
const clean = cleanActorAnimdefMetadata(metadata);
|
|
if (Array.isArray(clean.entity_types) && clean.entity_types.length) return clean;
|
|
|
|
const inferred = inferEntityTypeFromActorLabel(getActorExportLabel(actorIndex));
|
|
return inferred ? { ...clean, entity_types: [inferred] } : clean;
|
|
}
|
|
|
|
function addActorAnimdefFormFields(form, prefix, actorIndex, actorLabel, metadata, defaultActivity, condition, actorLabels) {
|
|
const base = `${prefix}_${actorIndex}`;
|
|
const fieldCondition = condition || undefined;
|
|
const targetOptions = {};
|
|
for (const label of actorLabels) {
|
|
if (label !== actorLabel) targetOptions[label] = label;
|
|
}
|
|
for (const label of metadata.injects_into || []) {
|
|
if (!Object.hasOwn(targetOptions, label)) targetOptions[label] = `${label} (unavailable - remove or replace)`;
|
|
}
|
|
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 || '',
|
|
description: 'Selecting an injector adds gender.male to required actor tags. You can remove that tag afterward.',
|
|
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 createInjectorGenderDefaultHandler(form) {
|
|
const roles = new Map(Object.entries(form).filter(([key]) => key.endsWith('_injector'))
|
|
.map(([key, field]) => [key, field.value || '']));
|
|
return (dialog, result) => {
|
|
const updates = {};
|
|
for (const [key, previous] of roles) {
|
|
if (!result || !(key in result)) continue;
|
|
const role = result[key];
|
|
roles.set(key, role);
|
|
if (role === previous || !['V', 'A', 'M'].includes(role)) continue;
|
|
const tagsKey = key.replace(/_injector$/, '_actor_tags');
|
|
const tags = uniqueTrimmedStrings(parseCommaSeparatedStrings(result[tagsKey]));
|
|
if (tags.includes('gender.male')) continue;
|
|
result[tagsKey] = updates[tagsKey] = [...tags, 'gender.male'].join(', ');
|
|
}
|
|
if (Object.keys(updates).length) dialog.form.setValues(updates, false);
|
|
};
|
|
}
|
|
|
|
function validateActorAnimdefMetadata(metadata, actorLabel) {
|
|
for (const entityType of metadata.entity_types || []) {
|
|
if (!RESOURCE_ID_PATTERN.test(entityType)) {
|
|
throw new Error(`Actor "${actorLabel}" has invalid entity type "${entityType}". Use namespace:path.`);
|
|
}
|
|
}
|
|
if (metadata.entity_variant && !/^[a-z0-9_]+$/.test(metadata.entity_variant)) {
|
|
throw new Error(`Actor "${actorLabel}" has invalid entity variant "${metadata.entity_variant}".`);
|
|
}
|
|
if (metadata.injects_into?.length && !metadata.injector) {
|
|
throw new Error(`Actor "${actorLabel}" needs an injector role to use injects_into.`);
|
|
}
|
|
for (const item of metadata.held_item?.items || []) {
|
|
const id = item.startsWith('#') ? item.slice(1) : item;
|
|
if (!RESOURCE_ID_PATTERN.test(id)) {
|
|
throw new Error(`Actor "${actorLabel}" has invalid held item or tag "${item}". Use namespace:path or #namespace:path.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getSortedUsedActorIndicesFromProject() {
|
|
return Array.from(getUsedActorIndicesFromProject()).sort((a, b) => a - b);
|
|
}
|
|
|
|
const convertPos = (p) => {
|
|
const x = p?.[0] ?? 0,
|
|
y = p?.[1] ?? 0,
|
|
z = p?.[2] ?? 0;
|
|
return [-x, y, z];
|
|
};
|
|
|
|
const convertRot = (r) => {
|
|
const x = r?.[0] ?? 0,
|
|
y = r?.[1] ?? 0,
|
|
z = r?.[2] ?? 0;
|
|
return [-x, -y, z];
|
|
};
|
|
|
|
function readActorIndexFromName(name) {
|
|
if (typeof name !== 'string' || !name.startsWith('actor')) return null;
|
|
|
|
let i = 5; // after 'actor'
|
|
let digits = '';
|
|
while (i < name.length) {
|
|
const c = name[i];
|
|
if (c < '0' || c > '9') break;
|
|
digits += c;
|
|
i++;
|
|
}
|
|
if (!digits || name[i] !== '_') return null;
|
|
|
|
const idx = parseInt(digits, 10);
|
|
return Number.isFinite(idx) && idx > 0 ? idx : null;
|
|
}
|
|
|
|
function getUsedActorIndicesFromProject() {
|
|
const used = new Set();
|
|
for (const g of Group.all) {
|
|
const idx = readActorIndexFromName(g?.name || '');
|
|
if (idx) used.add(idx);
|
|
}
|
|
return used;
|
|
}
|
|
|
|
function getNextActorIndex() {
|
|
const used = getUsedActorIndicesFromProject();
|
|
let i = 1;
|
|
while (used.has(i)) i++;
|
|
return i;
|
|
}
|
|
|
|
function extractGeckoGeometryRoot(json) {
|
|
return json?.['minecraft:geometry']?.[0] || null;
|
|
}
|
|
|
|
function applyPerFaceUVWithXFlip(cube, uvObj) {
|
|
if (!cube?.faces || !uvObj || typeof uvObj !== 'object') return;
|
|
|
|
const uv = { ...uvObj };
|
|
const tmp = uv.east;
|
|
uv.east = uv.west;
|
|
uv.west = tmp;
|
|
|
|
const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
|
|
for (const face of faces) {
|
|
const f = uv[face];
|
|
if (!f) continue;
|
|
|
|
const a = Array.isArray(f.uv) ? f.uv : null;
|
|
const s = Array.isArray(f.uv_size) ? f.uv_size : null;
|
|
if (!a || !s) continue;
|
|
|
|
const u1 = num(a[0], 0);
|
|
const v1 = num(a[1], 0);
|
|
const w = num(s[0], 0);
|
|
const h = num(s[1], 0);
|
|
|
|
if (cube.faces[face]) cube.faces[face].uv = [u1, v1, u1 + w, v1 + h];
|
|
}
|
|
|
|
cube.updateUV?.();
|
|
}
|
|
|
|
function validUvDimension(value, fallback = 64) {
|
|
return Number.isFinite(value) && value > 0 && value <= 8192 ? value : fallback;
|
|
}
|
|
|
|
function scaleCubeUvs(cube, x, y) {
|
|
if (typeof Format === 'undefined' || Format?.id !== 'geckolib_model') return;
|
|
if (x === 1 && y === 1) return;
|
|
if (cube.box_uv) {
|
|
if (typeof cube.setUVMode !== 'function') throw new Error('Box UV conversion is unavailable in this Blockbench version.');
|
|
// Blockbench derives face UVs from uv_offset only while box UV mode is active.
|
|
cube.preview_controller?.updateUV?.(cube);
|
|
cube.setUVMode(false);
|
|
Project.box_uv = false;
|
|
}
|
|
for (const face of Object.values(cube.faces || {})) {
|
|
if (!Array.isArray(face.uv) || face.uv.length !== 4) continue;
|
|
face.uv = [face.uv[0] * x, face.uv[1] * y, face.uv[2] * x, face.uv[3] * y];
|
|
}
|
|
cube.autouv = 0;
|
|
cube.preview_controller?.updateUV?.(cube);
|
|
}
|
|
|
|
function expandProjectUvs(width, height) {
|
|
if (typeof Format === 'undefined' || Format?.id !== 'geckolib_model') return false;
|
|
const oldWidth = validUvDimension(Project.texture_width), oldHeight = validUvDimension(Project.texture_height);
|
|
const nextWidth = Math.max(oldWidth, width), nextHeight = Math.max(oldHeight, height);
|
|
if (nextWidth === oldWidth && nextHeight === oldHeight) return false;
|
|
for (const cube of Cube.all) scaleCubeUvs(cube, nextWidth / oldWidth, nextHeight / oldHeight);
|
|
Project.texture_width = nextWidth;
|
|
Project.texture_height = nextHeight;
|
|
Canvas.updateAllUVs?.();
|
|
Project.saved = false;
|
|
return true;
|
|
}
|
|
|
|
function normalizeCubeUvs(cube, actorIndex, width, height, original = null) {
|
|
if (typeof Format === 'undefined' || Format?.id !== 'geckolib_model') return false;
|
|
width = validUvDimension(width); height = validUvDimension(height);
|
|
const all = getActorUvBasis(), actor = all[actorIndex];
|
|
const key = cube.uuid || cube.name;
|
|
const basis = original || actor?.cubes?.[key] || actor || {
|
|
width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) };
|
|
const oldWidth = validUvDimension(basis.width), oldHeight = validUvDimension(basis.height);
|
|
expandProjectUvs(width, height);
|
|
const x = oldWidth / width, y = oldHeight / height;
|
|
scaleCubeUvs(cube, x, y);
|
|
if (actor?.cubes?.[key]?.width !== width || actor?.cubes?.[key]?.height !== height) {
|
|
all[actorIndex] = { ...(actor || basis), cubes: { ...actor?.cubes, [key]: { width, height } } };
|
|
Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all);
|
|
Project.saved = false;
|
|
}
|
|
return x !== 1 || y !== 1;
|
|
}
|
|
|
|
function normalizeActorUvs(actorIndex, width, height, original = null) {
|
|
if (typeof Format === 'undefined' || Format?.id !== 'geckolib_model') return false;
|
|
let changed = false;
|
|
for (const cube of Cube.all) {
|
|
if (cubeBelongsToActor(cube, actorIndex)) changed = normalizeCubeUvs(cube, actorIndex, width, height, original) || changed;
|
|
}
|
|
expandProjectUvs(validUvDimension(width), validUvDimension(height));
|
|
setActorUvBasis(actorIndex, width, height);
|
|
return changed;
|
|
}
|
|
|
|
// ----------------------------
|
|
// Block requirement authoring
|
|
// ----------------------------
|
|
function getBlockRequirementEntries() {
|
|
try {
|
|
const entries = JSON.parse(Project?.[BLOCK_REQUIREMENTS_PROPERTY] || '{}');
|
|
return entries && typeof entries === 'object' && !Array.isArray(entries) ? entries : {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function getAuthoredBlockRequirement(animationId) {
|
|
return getBlockRequirementEntries()[animationId] || null;
|
|
}
|
|
|
|
function blockRequirementInteger(value, label, minimum = 1) {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < minimum || parsed > 32) {
|
|
throw new Error(`${label} must be a whole number between ${minimum} and 32.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function readBlockRequirementForm(result) {
|
|
const blocks = uniqueTrimmedStrings(parseCommaSeparatedStrings(result.blocks));
|
|
for (const block of blocks) {
|
|
if (!/^#?[a-z0-9_.-]+:[a-z0-9/._-]+$/.test(block)) {
|
|
throw new Error(`Invalid block ID/tag "${block}". Use minecraft:stone or #minecraft:fences.`);
|
|
}
|
|
}
|
|
const requirement = { type: result.type };
|
|
if (blocks.length) requirement.blocks = blocks.length === 1 ? blocks[0] : blocks;
|
|
if (result.type === 'wall') {
|
|
const minHeight = blockRequirementInteger(result.height_min, 'Minimum height');
|
|
const minWidth = blockRequirementInteger(result.width_min, 'Minimum width');
|
|
const maxHeight = String(result.height_max ?? '').trim()
|
|
? blockRequirementInteger(result.height_max, 'Maximum height') : null;
|
|
const maxWidth = String(result.width_max ?? '').trim()
|
|
? blockRequirementInteger(result.width_max, 'Maximum width') : null;
|
|
if (maxHeight !== null && maxHeight < minHeight) throw new Error('Maximum height must be at least the minimum height.');
|
|
if (maxWidth !== null && maxWidth < minWidth) throw new Error('Maximum width must be at least the minimum width.');
|
|
requirement.height = { min: minHeight };
|
|
if (maxHeight !== null) requirement.height.max = maxHeight;
|
|
requirement.width = { min: minWidth };
|
|
if (maxWidth !== null) {
|
|
requirement.width.max = maxWidth;
|
|
if (result.both_axes) requirement.width.both_axes = true;
|
|
if (result.include_obstructions) requirement.width.include_obstructions = true;
|
|
}
|
|
if (result.facing) {
|
|
if (!blocks.length) throw new Error('A facing requirement needs a block ID or tag.');
|
|
requirement.facing = result.facing;
|
|
}
|
|
} else if (result.type === 'center_support') {
|
|
requirement.support = result.support;
|
|
if (result.support === 'surface' && !blocks.length) throw new Error('Custom surface support requires a block ID or tag.');
|
|
requirement.placement = result.placement;
|
|
if (result.placement === 'surface_footprint') {
|
|
requirement.surface_footprint = {
|
|
width: blockRequirementInteger(result.footprint_width, 'Footprint width'),
|
|
depth: blockRequirementInteger(result.footprint_depth, 'Footprint depth'),
|
|
height: blockRequirementInteger(result.footprint_height, 'Footprint clearance height'),
|
|
margin: blockRequirementInteger(result.footprint_margin, 'Footprint margin', 0),
|
|
};
|
|
} else {
|
|
requirement.surface_radius = blockRequirementInteger(result.surface_radius, 'Surface radius', 0);
|
|
}
|
|
} else {
|
|
throw new Error('Choose a wall or center support requirement.');
|
|
}
|
|
if (requirement.placement !== 'surface_footprint') {
|
|
requirement.clearance = {
|
|
width: blockRequirementInteger(result.clearance_width, 'Clearance width'),
|
|
height: blockRequirementInteger(result.clearance_height, 'Clearance height'),
|
|
depth: blockRequirementInteger(result.clearance_depth, 'Clearance depth'),
|
|
};
|
|
}
|
|
const wallThickness = Number(result.wall_thickness ?? 1);
|
|
const surfaceHeight = Number(result.surface_height ?? 1);
|
|
if (!Number.isFinite(wallThickness) || wallThickness <= 0 || wallThickness > 1) throw new Error('Preview wall thickness must be above 0 and at most 1 block.');
|
|
if (!Number.isFinite(surfaceHeight) || surfaceHeight <= 0 || surfaceHeight > 2) throw new Error('Preview surface height must be above 0 and at most 2 blocks.');
|
|
return { requirement, preview: { wallThickness, surfaceHeight } };
|
|
}
|
|
|
|
function buildBlockRequirementGuideBoxes(entry) {
|
|
const req = entry.requirement;
|
|
const boxes = [];
|
|
const add = (kind, from, to, label) => {
|
|
if (boxes.length >= 4096) throw new Error('This preview would exceed 4096 guide cubes. Reduce its dimensions.');
|
|
boxes.push({ kind, from: from.map((value) => value * 16), to: to.map((value) => value * 16), label });
|
|
};
|
|
const cell = (kind, x, y, z, height = 1, label = kind) =>
|
|
add(kind, [x, y, z], [x + 1, y + height, z + 1], label);
|
|
const clearance = req.clearance;
|
|
if (req.type === 'wall') {
|
|
const width = req.width.min;
|
|
const thickness = entry.preview?.wallThickness ?? 1;
|
|
// NoN places the anchor half a block away from the wall's collision surface.
|
|
const gridZ = -1 - thickness / 2;
|
|
const left = -width / 2;
|
|
for (let x = 0; x < width; x++) {
|
|
for (let y = 0; y < req.height.min; y++) {
|
|
add('required', [left + x, y, -0.5 - thickness], [left + x + 1, y + 1, -0.5], 'wall');
|
|
}
|
|
cell('required', left + x, -1, gridZ, 1, 'floor');
|
|
if (req.height.max === req.height.min) cell('clearance', left + x, req.height.max, gridZ, 1, 'height_cap');
|
|
}
|
|
const padding = clearance.width - 1;
|
|
for (let x = -padding; x < width + padding; x++) {
|
|
for (let z = 1; z <= clearance.depth; z++) {
|
|
for (let y = 0; y < clearance.height; y++) cell('clearance', left + x, y, gridZ + z);
|
|
cell('required', left + x, -1, gridZ + z, 1, 'floor');
|
|
}
|
|
}
|
|
} else {
|
|
const top = req.support === 'slab' || req.support === 'half_height' ? 0.5
|
|
: req.support === 'full_block' ? 1 : entry.preview?.surfaceHeight ?? 1;
|
|
if (req.placement === 'surface_footprint') {
|
|
const footprint = req.surface_footprint;
|
|
for (let x = -footprint.margin; x < footprint.width + footprint.margin; x++) {
|
|
for (let z = -footprint.margin; z < footprint.depth + footprint.margin; z++) {
|
|
if (x >= 0 && x < footprint.width && z >= 0 && z < footprint.depth) {
|
|
cell('required', x - 0.5, 0, z - 0.5, top, 'support');
|
|
}
|
|
for (let y = 0; y < footprint.height; y++) cell('clearance', x - 0.5, top + y, z - 0.5);
|
|
}
|
|
}
|
|
} else {
|
|
cell('required', -0.5, 0, -0.5, top, 'support');
|
|
const radius = req.surface_radius || 0;
|
|
for (let x = -radius; x <= radius; x++) {
|
|
for (let z = -radius; z <= radius; z++) {
|
|
for (let y = 0; y < clearance.height; y++) cell('clearance', x - 0.5, top + y, z - 0.5, 1, 'above_support');
|
|
}
|
|
}
|
|
const padding = clearance.width - 1;
|
|
for (let x = -padding; x <= padding; x++) {
|
|
for (let z = 1; z <= clearance.depth; z++) {
|
|
for (let y = 0; y < clearance.height; y++) cell('clearance', x - 0.5, y, z - 0.5);
|
|
cell('required', x - 0.5, -1, z - 0.5, 1, 'floor');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Merge overlapping clearance checks (surface-radius and directional checks can overlap).
|
|
const columns = new Map();
|
|
for (const box of boxes) {
|
|
const key = [box.kind, box.from[0], box.from[2], box.to[0], box.to[2]].join(':');
|
|
if (!columns.has(key)) columns.set(key, []);
|
|
columns.get(key).push(box);
|
|
}
|
|
const merged = [];
|
|
for (const column of columns.values()) {
|
|
column.sort((a, b) => a.from[1] - b.from[1]);
|
|
let current;
|
|
for (const box of column) {
|
|
if (current && box.from[1] < current.to[1]) current.to[1] = Math.max(current.to[1], box.to[1]);
|
|
else { current = { ...box, from: box.from.slice(), to: box.to.slice() }; merged.push(current); }
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function isBlockRequirementGuide(node) {
|
|
if (String(node?.name || '').startsWith(BLOCK_GUIDE_PREFIX)) return true;
|
|
return node?.getAllAncestors?.().some((parent) => String(parent.name || '').startsWith(BLOCK_GUIDE_PREFIX)) || false;
|
|
}
|
|
|
|
function applyBlockRequirementGuideMaterial(cube) {
|
|
if (!isBlockRequirementGuide(cube) || !cube.mesh || typeof THREE === 'undefined') return;
|
|
const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required';
|
|
let material = blockGuideMaterials.get(kind);
|
|
if (!material) {
|
|
material = new THREE.MeshBasicMaterial({ color: BLOCK_GUIDE_COLORS[kind], transparent: true,
|
|
opacity: 0.22, depthWrite: false, side: THREE.DoubleSide });
|
|
blockGuideMaterials.set(kind, material);
|
|
}
|
|
cube.mesh.material = material;
|
|
}
|
|
|
|
function hideInternalBlockGuideFaces(cubes) {
|
|
const faces = [['west', 0, 0], ['east', 0, 1], ['down', 1, 0], ['up', 1, 1], ['north', 2, 0], ['south', 2, 1]];
|
|
const planes = new Map();
|
|
const descriptors = [];
|
|
const epsilon = 0.000001;
|
|
for (const cube of cubes) {
|
|
const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required';
|
|
for (const [name, axis, positive] of faces) {
|
|
if (!cube.faces?.[name]) continue;
|
|
const axes = [0, 1, 2].filter((value) => value !== axis);
|
|
const rect = [cube.from[axes[0]], cube.from[axes[1]], cube.to[axes[0]], cube.to[axes[1]]];
|
|
const key = `${kind}:${axis}:${(positive ? cube.to[axis] : cube.from[axis]).toFixed(6)}`;
|
|
const descriptor = { cube, name, positive, rect, key };
|
|
if (!planes.has(key)) planes.set(key, [[], []]);
|
|
planes.get(key)[positive].push(descriptor);
|
|
descriptors.push(descriptor);
|
|
}
|
|
}
|
|
for (const face of descriptors) {
|
|
let uncovered = [face.rect];
|
|
// Subtract neighboring rectangles; several smaller faces can cover one large face.
|
|
for (const neighbor of planes.get(face.key)[1 - face.positive]) {
|
|
if (neighbor.cube === face.cube) continue;
|
|
const next = [];
|
|
for (const rect of uncovered) {
|
|
const left = Math.max(rect[0], neighbor.rect[0]), bottom = Math.max(rect[1], neighbor.rect[1]);
|
|
const right = Math.min(rect[2], neighbor.rect[2]), top = Math.min(rect[3], neighbor.rect[3]);
|
|
if (right - left <= epsilon || top - bottom <= epsilon) { next.push(rect); continue; }
|
|
if (left - rect[0] > epsilon) next.push([rect[0], rect[1], left, rect[3]]);
|
|
if (rect[2] - right > epsilon) next.push([right, rect[1], rect[2], rect[3]]);
|
|
if (bottom - rect[1] > epsilon) next.push([left, rect[1], right, bottom]);
|
|
if (rect[3] - top > epsilon) next.push([left, top, right, rect[3]]);
|
|
}
|
|
uncovered = next;
|
|
if (!uncovered.length) break;
|
|
}
|
|
// Null faces are omitted from Blockbench's mesh index, not just made transparent.
|
|
face.cube.faces[face.name].texture = uncovered.length ? false : null;
|
|
}
|
|
}
|
|
|
|
function refreshBlockGuideFaces() {
|
|
const roots = Group.all.filter((group) => group.name.startsWith(BLOCK_GUIDE_PREFIX)
|
|
&& !isBlockRequirementGuide(group.parent));
|
|
const elements = [];
|
|
for (const root of roots) {
|
|
const cubes = Cube.all.filter((cube) => cube.getAllAncestors().includes(root));
|
|
hideInternalBlockGuideFaces(cubes);
|
|
elements.push(...cubes);
|
|
}
|
|
if (elements.length) Canvas.updateView({ elements, element_aspects: { faces: true, uv: true } });
|
|
}
|
|
|
|
function rebuildBlockRequirementGuides(animationId, entry, importedDefinition, manageUndo = true, importedProps) {
|
|
const boxes = entry ? buildBlockRequirementGuideBoxes(entry) : [];
|
|
const groupName = `${BLOCK_GUIDE_PREFIX}${animationId}`;
|
|
const oldGroups = Group.all.filter((group) => group.name === groupName);
|
|
const oldElements = Cube.all.filter((cube) => cube.getAllAncestors().some((group) => oldGroups.includes(group)));
|
|
const entries = getBlockRequirementEntries();
|
|
const updated = { ...entries };
|
|
if (entry) updated[animationId] = entry;
|
|
else updated[animationId] = { requirement: null };
|
|
const createdElements = [];
|
|
const createdGroups = [];
|
|
if (manageUndo) Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups,
|
|
block_requirement_edit: true, animdef_edit: importedDefinition !== undefined,
|
|
prop_preview_edit: importedProps !== undefined });
|
|
if (entry) {
|
|
const root = oldGroups[0] || new Group({ name: groupName, export: false });
|
|
if (!oldGroups.length) { root.init(); root.addTo('root'); }
|
|
createdGroups.push(root);
|
|
boxes.forEach((box, index) => {
|
|
const data = { name: `${BLOCK_GUIDE_PREFIX}${box.kind}_${box.label}_${index + 1}`,
|
|
from: box.from, to: box.to, export: false, box_uv: false };
|
|
const cube = oldElements[index] || new Cube(data);
|
|
if (oldElements[index]) {
|
|
cube.name = data.name;
|
|
cube.from = box.from.slice();
|
|
cube.to = box.to.slice();
|
|
cube.export = false;
|
|
} else { cube.init(); cube.addTo(root); }
|
|
createdElements.push(cube);
|
|
});
|
|
for (const cube of oldElements.slice(boxes.length)) cube.remove();
|
|
} else {
|
|
for (const group of oldGroups) group.remove();
|
|
}
|
|
Project[BLOCK_REQUIREMENTS_PROPERTY] = JSON.stringify(updated);
|
|
if (importedDefinition !== undefined) saveAnimdef(animationId, importedDefinition);
|
|
if (importedProps !== undefined) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(importedProps);
|
|
hideInternalBlockGuideFaces(createdElements);
|
|
// Rebuilding every cube would reset forced actor materials in single-texture formats.
|
|
Canvas.updateView({ elements: createdElements, groups: createdGroups,
|
|
element_aspects: { transform: true, geometry: true, faces: true, uv: true, visibility: true } });
|
|
for (const cube of createdElements) applyBlockRequirementGuideMaterial(cube);
|
|
if (manageUndo) Undo.finishEdit(importedDefinition !== undefined ? 'Import animation definition' : entry ? 'Set block requirement' : 'Remove block requirement',
|
|
{ outliner: true, elements: createdElements, groups: createdGroups, block_requirement_edit: true,
|
|
animdef_edit: importedDefinition !== undefined, prop_preview_edit: importedProps !== undefined });
|
|
Project.saved = false;
|
|
}
|
|
|
|
function blockRequirementPreviewDefaults(requirement) {
|
|
const blocks = Array.isArray(requirement.blocks) ? requirement.blocks : requirement.blocks ? [requirement.blocks] : [];
|
|
const beds = /^(?:#minecraft:beds|minecraft:(?:white|orange|magenta|light_blue|yellow|lime|pink|gray|light_gray|cyan|purple|blue|brown|green|red|black)_bed)$/;
|
|
const fences = /^(?:#minecraft:(?:fences|wooden_fences)|minecraft:(?:oak|spruce|birch|jungle|acacia|dark_oak|mangrove|cherry|bamboo|pale_oak|crimson|warped|nether_brick)_fence)$/;
|
|
return {
|
|
wallThickness: requirement.type === 'wall' && blocks.length && blocks.every((block) => fences.test(block)) ? 0.25 : 1,
|
|
surfaceHeight: requirement.type === 'center_support' && blocks.length && blocks.every((block) => beds.test(block)) ? 0.5625 : 1,
|
|
};
|
|
}
|
|
|
|
function buildBlockRequirementForm(animationId, importedRequirement) {
|
|
const entry = getAuthoredBlockRequirement(animationId);
|
|
const req = importedRequirement ?? (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) ?? {};
|
|
const preview = importedRequirement !== undefined ? blockRequirementPreviewDefaults(req)
|
|
: entry?.preview ?? blockRequirementPreviewDefaults(req);
|
|
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 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: 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: 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) {
|
|
validateImportGeometry({ 'minecraft:geometry': [geo] });
|
|
const bones = geo.bones;
|
|
|
|
const prefix = `actor${actorIndex}_`;
|
|
const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null;
|
|
|
|
if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: Cube.all.slice(), textures: [],
|
|
uv_mode: true, actor_uv_edit: true,
|
|
animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] });
|
|
let editing = manageUndo;
|
|
try {
|
|
if (replaceExisting) removeActorGeometry(actorIndex);
|
|
const uvWidth = validUvDimension(geo?.description?.texture_width);
|
|
const uvHeight = validUvDimension(geo?.description?.texture_height);
|
|
expandProjectUvs(uvWidth, uvHeight);
|
|
|
|
const boneByName = new Map();
|
|
for (const b of bones) if (b?.name) boneByName.set(b.name, b);
|
|
|
|
const groupByBoneName = new Map();
|
|
for (const boneName of boneByName.keys()) {
|
|
const g = new Group({ name: prefix + boneName });
|
|
g.init();
|
|
groupByBoneName.set(boneName, g);
|
|
}
|
|
|
|
// parenting
|
|
for (const [boneName, b] of boneByName) {
|
|
const g = groupByBoneName.get(boneName);
|
|
const parentGroup = b?.parent ? groupByBoneName.get(b.parent) : null;
|
|
g.addTo(parentGroup || 'root');
|
|
}
|
|
|
|
// pivots + rotations
|
|
for (const [boneName, b] of boneByName) {
|
|
const g = groupByBoneName.get(boneName);
|
|
g.origin = convertPos(v3(b.pivot, 0));
|
|
g.rotation = convertRot(v3(b.rotation, 0));
|
|
|
|
const locators = b?.locators && typeof b.locators === 'object' && !Array.isArray(b.locators)
|
|
? b.locators : {};
|
|
for (const [locatorName, locatorData] of Object.entries(locators)) {
|
|
const position = Array.isArray(locatorData)
|
|
? locatorData
|
|
: locatorData?.offset || locatorData?.position || locatorData?.from;
|
|
if (!Array.isArray(position)) continue;
|
|
|
|
const locator = new Locator({
|
|
name: `${prefix}${locatorName}`,
|
|
from: convertPos(v3(position, 0)),
|
|
});
|
|
locator.init();
|
|
locator.addTo(g);
|
|
}
|
|
}
|
|
|
|
// cubes
|
|
for (const [boneName, b] of boneByName) {
|
|
const g = groupByBoneName.get(boneName);
|
|
const cubes = Array.isArray(b?.cubes) ? b.cubes : [];
|
|
|
|
for (let i = 0; i < cubes.length; i++) {
|
|
const c = cubes[i];
|
|
const origin = v3(c?.origin, 0);
|
|
const size = v3(c?.size, 0);
|
|
|
|
const p1 = convertPos(origin);
|
|
const p2 = convertPos([origin[0] + size[0], origin[1] + size[1], origin[2] + size[2]]);
|
|
|
|
const from = [Math.min(p1[0], p2[0]), Math.min(p1[1], p2[1]), Math.min(p1[2], p2[2])];
|
|
const to = [Math.max(p1[0], p2[0]), Math.max(p1[1], p2[1]), Math.max(p1[2], p2[2])];
|
|
|
|
const cube = new Cube({
|
|
name: `${prefix}${boneName}_cube${i + 1}`,
|
|
from,
|
|
to,
|
|
inflate: num(c?.inflate, 0),
|
|
});
|
|
|
|
cube.init();
|
|
cube.addTo(g);
|
|
|
|
if (c?.pivot) cube.origin = convertPos(v3(c.pivot, 0));
|
|
if (c?.rotation) cube.rotation = convertRot(v3(c.rotation, 0));
|
|
|
|
if (Array.isArray(c?.uv)) {
|
|
cube.box_uv = true;
|
|
cube.uv_offset = [num(c.uv[0], 0), num(c.uv[1], 0)];
|
|
cube.preview_controller?.updateUV?.(cube);
|
|
} else if (c?.uv && typeof c.uv === 'object') {
|
|
cube.box_uv = false;
|
|
applyPerFaceUVWithXFlip(cube, c.uv);
|
|
}
|
|
}
|
|
}
|
|
|
|
normalizeActorUvs(actorIndex, uvWidth, uvHeight, {
|
|
width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) });
|
|
|
|
const animationResult = replaceExisting
|
|
? rebindActorAnimations(actorIndex, previousBindings)
|
|
: { rebound: 0, missing: 0 };
|
|
|
|
if (manageUndo) {
|
|
Canvas.updateAll();
|
|
applyAllActorTexturePreviews(false);
|
|
}
|
|
if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select();
|
|
if (manageUndo) {
|
|
Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`);
|
|
editing = false;
|
|
}
|
|
|
|
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'],
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (editing) Undo.cancelEdit();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function pngDimensions(bytes) {
|
|
if (!bytes || bytes.length < 24 || bytes.toString('hex', 0, 8) !== '89504e470d0a1a0a') return null;
|
|
const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20);
|
|
return width > 0 && height > 0 && width <= 8192 && height <= 8192 ? { width, height } : null;
|
|
}
|
|
|
|
function actorTextureCandidates(json, geo, modelPath, entityId = '', slimPlayer = false) {
|
|
const candidateIds = [];
|
|
const addId = (id) => { if (importResourceId(id) && !candidateIds.includes(id)) candidateIds.push(id); };
|
|
if (typeof json.afw_texture === 'string') addId(json.afw_texture);
|
|
const path = String(modelPath || '').replace(/\\/g, '/');
|
|
const namespace = /\/assets\/([^/]+)\//.exec(path)?.[1] || 'minecraft';
|
|
const identifier = String(geo?.description?.identifier || '').split('.').pop();
|
|
const filename = path.split('/').pop()?.replace(/(?:\.(?:m|f|mf|m\+f))?\.geo\.json$/i, '') || '';
|
|
for (const entity of [...new Set([identifier, filename])]) {
|
|
if (!/^[a-z0-9_]+$/.test(entity)) continue;
|
|
addId(`${namespace}:textures/entity/${entity}/${entity}.png`);
|
|
addId(`${namespace}:textures/entity/${entity}.png`);
|
|
if (namespace !== 'minecraft') {
|
|
addId(`minecraft:textures/entity/${entity}/${entity}.png`);
|
|
addId(`minecraft:textures/entity/${entity}.png`);
|
|
}
|
|
}
|
|
if (entityId === 'minecraft:player' || filename === 'player' || filename === 'player_slim') {
|
|
addId(`minecraft:textures/entity/player/${slimPlayer || filename === 'player_slim' ? 'slim' : 'wide'}/alex.png`);
|
|
}
|
|
return candidateIds;
|
|
}
|
|
|
|
function importTextureDimensions(bytes, metadata = null) {
|
|
let dimensions = pngDimensions(bytes);
|
|
if (!dimensions) return null;
|
|
const animation = metadata?.animation;
|
|
if (animation) {
|
|
const width = validUvDimension(animation.width, dimensions.width);
|
|
const height = validUvDimension(animation.height, width);
|
|
if (width <= dimensions.width && height <= dimensions.height) dimensions = { width, height };
|
|
}
|
|
return dimensions;
|
|
}
|
|
|
|
function tryLoadImportedActorTexture(json, geo, modelPath, actorIndex) {
|
|
let io;
|
|
try { io = desktopFiles(); } catch (_) { return; }
|
|
const candidateIds = actorTextureCandidates(json, geo, modelPath);
|
|
if (!candidateIds.length) return;
|
|
const sourcePaths = [];
|
|
try { sourcePaths.push(findImportPack(modelPath, io).root); } catch (_) { /* Standalone model. */ }
|
|
try { sourcePaths.push(...savedPackFolders()); } catch (_) { /* No registered packs. */ }
|
|
try { sourcePaths.push(...discoverPropSources(io).map((source) => source.file)); } catch (_) { /* No cached assets. */ }
|
|
const sources = createImportAssetLookup(io, [...new Set(sourcePaths)]);
|
|
for (const id of candidateIds) {
|
|
for (const source of sources.sources()) {
|
|
const parsed = importResourceId(id);
|
|
const relative = `assets/${parsed.namespace}/${parsed.resource}`;
|
|
let bytes;
|
|
try { bytes = source.read(relative); } catch (_) { continue; }
|
|
if (!bytes || bytes.length > 4 * 1024 * 1024) continue;
|
|
let dimensions = pngDimensions(bytes);
|
|
if (!dimensions) continue;
|
|
try {
|
|
dimensions = importTextureDimensions(bytes, propJson(source, `${relative}.mcmeta`));
|
|
} catch (_) { /* Keep the PNG dimensions when metadata is invalid. */ }
|
|
const edit = { elements: Cube.all.filter((cube) => cubeBelongsToActor(cube, actorIndex)),
|
|
textures: Texture.all.slice(), uv_mode: true, actor_uv_edit: true, actor_texture_edit: true };
|
|
Undo.initEdit(edit);
|
|
try {
|
|
const name = `actor${actorIndex}_${parsed.namespace}_${parsed.resource.split('/').pop()}`;
|
|
const texture = new Texture({ name, uv_width: dimensions.width, uv_height: dimensions.height });
|
|
texture.keep_size = true;
|
|
const projectId = Project.uuid;
|
|
texture.load_callback = () => { if (Project?.uuid === projectId) scheduleActorTexturePreviewApply(); };
|
|
texture.fromDataURL(`data:image/png;base64,${bytes.toString('base64')}`);
|
|
texture.name = name;
|
|
texture.relative_path = relative;
|
|
texture.add(false);
|
|
const selected = getProjectActorTextures();
|
|
selected[actorIndex] = name;
|
|
setProjectActorTextures(selected);
|
|
normalizeActorUvs(actorIndex, dimensions.width, dimensions.height);
|
|
applyActorTexturePreview(actorIndex, name);
|
|
Undo.finishEdit(`Load Actor ${actorIndex} Texture`, { ...edit, elements: Cube.all.slice(), textures: Texture.all.slice() });
|
|
return;
|
|
} catch (error) { Undo.cancelEdit(); throw error; }
|
|
}
|
|
}
|
|
}
|
|
|
|
function importActorGeckoJson() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
Filesystem.importFile(
|
|
{
|
|
type: 'GeckoLib / Bedrock Geometry JSON',
|
|
extensions: ['json', 'geo.json'],
|
|
multiple: false,
|
|
readtype: 'text',
|
|
resource_id: 'model',
|
|
title: 'Import Actor (.json / .geo.json) with actorN_ prefix',
|
|
},
|
|
(files) => {
|
|
if (!files?.length) return;
|
|
|
|
try {
|
|
const json = JSON.parse(files[0]?.content || '');
|
|
const geo = extractGeckoGeometryRoot(json);
|
|
|
|
if (!geo) {
|
|
Blockbench.showQuickMessage('Unsupported JSON structure (expected minecraft:geometry[0]).', 3000);
|
|
return;
|
|
}
|
|
|
|
const actorIndex = getNextActorIndex();
|
|
importGeometryIntoCurrentProject(geo, actorIndex);
|
|
tryLoadImportedActorTexture(json, geo, files[0]?.path, actorIndex);
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showQuickMessage(`Import failed: ${e?.message || e}`, 4000);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function replaceActorGeckoJson() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
const actorOptions = buildActorSelectOptions();
|
|
if (!Object.keys(actorOptions).length) {
|
|
Blockbench.showQuickMessage('No actorN_ model found in this project.', 2500);
|
|
return;
|
|
}
|
|
|
|
new Dialog({
|
|
id: 'replace_actor_gecko_json',
|
|
title: 'Replace Actor Model',
|
|
width: 520,
|
|
form: {
|
|
actor: {
|
|
label: 'Actor to replace',
|
|
type: 'select',
|
|
options: actorOptions,
|
|
value: Object.keys(actorOptions)[0],
|
|
},
|
|
_info: {
|
|
type: 'info',
|
|
text: 'The selected actor model will be replaced. Its actor prefix, animations, and actor preferences are preserved.',
|
|
},
|
|
},
|
|
buttons: ['Cancel', 'Choose Model'],
|
|
cancelIndex: 0,
|
|
confirmIndex: 1,
|
|
onConfirm: (result) => {
|
|
const actorIndex = Number(result?.actor);
|
|
if (!Number.isFinite(actorIndex) || actorIndex <= 0) {
|
|
Blockbench.showQuickMessage('Choose a valid actor.', 2500);
|
|
return false;
|
|
}
|
|
|
|
Filesystem.importFile(
|
|
{
|
|
type: 'GeckoLib / Bedrock Geometry JSON',
|
|
extensions: ['json', 'geo.json'],
|
|
multiple: false,
|
|
readtype: 'text',
|
|
resource_id: 'model',
|
|
title: 'Select replacement actor model',
|
|
},
|
|
(files) => {
|
|
if (!files?.length) return;
|
|
try {
|
|
const json = JSON.parse(files[0]?.content || '');
|
|
const geo = extractGeckoGeometryRoot(json);
|
|
if (!geo) {
|
|
Blockbench.showQuickMessage('Unsupported JSON structure (expected minecraft:geometry[0]).', 3000);
|
|
return;
|
|
}
|
|
importGeometryIntoCurrentProject(geo, actorIndex, true);
|
|
tryLoadImportedActorTexture(json, geo, files[0]?.path, actorIndex);
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showQuickMessage(`Replacement failed: ${e?.message || e}`, 4000);
|
|
}
|
|
}
|
|
);
|
|
return true;
|
|
},
|
|
}).show();
|
|
}
|
|
|
|
// ----------------------------
|
|
// Actor Preferences
|
|
// ----------------------------
|
|
function editActorPreferences() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
const actorIndices = getSortedUsedActorIndicesFromProject();
|
|
if (!actorIndices.length) {
|
|
Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500);
|
|
return;
|
|
}
|
|
|
|
const names = getProjectActorNames();
|
|
const textures = getProjectActorTextures();
|
|
const animdefMetadata = getProjectActorAnimdefMetadata();
|
|
const boneTextureOverrides = getProjectBoneTextureOverrides();
|
|
const textureOverrideGroups = getGroupsForTextureOverrides();
|
|
const loadedTextureNames = (Array.isArray(Texture.all) ? Texture.all : [])
|
|
.map((t) => getTextureDisplayName(t))
|
|
.filter((name) => name);
|
|
const textureOptions = buildTextureSelectOptions(textures, boneTextureOverrides);
|
|
let showBoneTextureOverrides = false;
|
|
let showAnimdefMetadata = false;
|
|
|
|
const form = {
|
|
_info: {
|
|
type: 'info',
|
|
text:
|
|
'Set export names, actor preview textures, animation-definition metadata, and folder texture overrides for this project. Folder overrides apply recursively and take priority over actor textures. Loaded textures: ' +
|
|
(loadedTextureNames.length ? loadedTextureNames.join(', ') : '(none)'),
|
|
},
|
|
};
|
|
|
|
for (const idx of actorIndices) {
|
|
form[`name_${idx}`] = {
|
|
label: `actor${idx} export name`,
|
|
type: 'text',
|
|
value: names[String(idx)] || '',
|
|
placeholder: `actor${idx}`,
|
|
};
|
|
form[`texture_${idx}`] = {
|
|
label: `actor${idx} preview texture`,
|
|
type: 'select',
|
|
options: textureOptions,
|
|
value: textures[String(idx)] || '',
|
|
};
|
|
}
|
|
|
|
form.show_animdef_metadata = {
|
|
label: 'Animation definition metadata',
|
|
type: 'checkbox',
|
|
style: 'toggle_switch',
|
|
value: showAnimdefMetadata,
|
|
description: 'Show persistent AFW actor constraints used by animation-definition exports.',
|
|
};
|
|
form._animdef_info = {
|
|
type: 'info',
|
|
text: 'AFW actor metadata',
|
|
condition: (result) => result?.show_animdef_metadata ?? showAnimdefMetadata,
|
|
};
|
|
actorIndices.forEach((idx, actorPosition) => {
|
|
const label = names[String(idx)] || `actor${idx}`;
|
|
const metadata = actorAnimdefMetadataWithInference(idx, animdefMetadata[String(idx)]);
|
|
addActorAnimdefFormFields(
|
|
form,
|
|
'preferences_animdef',
|
|
idx,
|
|
label,
|
|
metadata,
|
|
actorPosition === 0 ? 'active' : 'passive',
|
|
(result) => result?.show_animdef_metadata ?? showAnimdefMetadata,
|
|
actorIndices.map((actor) => names[String(actor)] || `actor${actor}`)
|
|
);
|
|
});
|
|
|
|
if (textureOverrideGroups.length) {
|
|
form.show_bone_texture_overrides = {
|
|
label: 'Folder texture overrides',
|
|
type: 'checkbox',
|
|
style: 'toggle_switch',
|
|
value: showBoneTextureOverrides,
|
|
description: 'Show recursive folder-level texture overrides. These take priority over actor textures.',
|
|
};
|
|
form._bone_info = {
|
|
type: 'info',
|
|
text: 'Folder texture overrides',
|
|
condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides,
|
|
};
|
|
}
|
|
|
|
for (let i = 0; i < textureOverrideGroups.length; i++) {
|
|
const group = textureOverrideGroups[i];
|
|
const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides);
|
|
form[`bone_texture_${i}`] = {
|
|
label: getGroupTextureOverrideLabel(group),
|
|
type: 'select',
|
|
options: textureOptions,
|
|
value: saved?.texture || '',
|
|
condition: (result) => result?.show_bone_texture_overrides ?? showBoneTextureOverrides,
|
|
};
|
|
}
|
|
|
|
const updateInjectorGenderDefault = createInjectorGenderDefaultHandler(form);
|
|
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;
|
|
updateInjectorGenderDefault(this, result);
|
|
},
|
|
onConfirm: (result) => {
|
|
let editing = false;
|
|
try {
|
|
const outNames = {};
|
|
const outTextures = {};
|
|
const outAnimdefMetadata = {};
|
|
const outBoneTextureOverrides = {};
|
|
for (const idx of actorIndices) {
|
|
const name = String(result?.[`name_${idx}`] || '').trim();
|
|
const texture = String(result?.[`texture_${idx}`] || '').trim();
|
|
if (name) outNames[String(idx)] = name;
|
|
if (texture) outTextures[String(idx)] = texture;
|
|
const metadata = result?.show_animdef_metadata
|
|
? readActorAnimdefFormFields(result, 'preferences_animdef', idx)
|
|
: cleanActorAnimdefMetadata(animdefMetadata[String(idx)]);
|
|
validateActorAnimdefMetadata(metadata, name || `actor${idx}`);
|
|
if (Object.keys(metadata).length) outAnimdefMetadata[String(idx)] = metadata;
|
|
}
|
|
for (let i = 0; i < textureOverrideGroups.length; i++) {
|
|
const group = textureOverrideGroups[i];
|
|
const key = getGroupTextureOverrideKey(group);
|
|
const resultKey = `bone_texture_${i}`;
|
|
const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides);
|
|
const rawTexture = Object.prototype.hasOwnProperty.call(result || {}, resultKey) ? result[resultKey] : saved?.texture;
|
|
const texture = String(rawTexture || '').trim();
|
|
if (texture) {
|
|
outBoneTextureOverrides[key] = {
|
|
uuid: String(group.uuid || '').trim(),
|
|
name: String(group.name || '').trim(),
|
|
texture,
|
|
};
|
|
}
|
|
}
|
|
const aspects = { elements: Cube.all.slice(), uv_mode: true, actor_preferences_edit: true };
|
|
Undo.initEdit(aspects);
|
|
editing = true;
|
|
remapActorReferences(names, outNames, actorIndices, outAnimdefMetadata);
|
|
setProjectActorNames(outNames);
|
|
setProjectActorTextures(outTextures);
|
|
setProjectActorAnimdefMetadata(outAnimdefMetadata);
|
|
setProjectBoneTextureOverrides(outBoneTextureOverrides);
|
|
applyAllActorTexturePreviews(true);
|
|
Undo.finishEdit('Edit Actor Preferences', { ...aspects, elements: Cube.all.slice() });
|
|
void refreshPropPreviews(true);
|
|
return true;
|
|
} catch (e) {
|
|
if (editing) Undo.cancelEdit();
|
|
Blockbench.showMessageBox({
|
|
title: 'Actor Preferences',
|
|
message: e?.message || String(e),
|
|
buttons: ['OK'],
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
|
|
dialog.show();
|
|
}
|
|
|
|
function editBoneTextureOverrides() {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
const groups = getGroupsForTextureOverrides();
|
|
if (!groups.length) {
|
|
Blockbench.showQuickMessage('No folders found in this project.', 2500);
|
|
return;
|
|
}
|
|
|
|
const boneTextureOverrides = getProjectBoneTextureOverrides();
|
|
const loadedTextureNames = (Array.isArray(Texture.all) ? Texture.all : [])
|
|
.map((t) => getTextureDisplayName(t))
|
|
.filter((name) => name);
|
|
const textureOptions = buildTextureSelectOptions({}, boneTextureOverrides);
|
|
|
|
const form = {
|
|
_info: {
|
|
type: 'info',
|
|
text:
|
|
'Assign preview/export texture overrides to individual folders. Overrides apply recursively to child folders and cubes. Loaded textures: ' +
|
|
(loadedTextureNames.length ? loadedTextureNames.join(', ') : '(none)'),
|
|
},
|
|
};
|
|
|
|
for (let i = 0; i < groups.length; i++) {
|
|
const group = groups[i];
|
|
const saved = getBoneTextureOverrideForGroup(group, boneTextureOverrides);
|
|
form[`bone_texture_${i}`] = {
|
|
label: getGroupTextureOverrideLabel(group),
|
|
type: 'select',
|
|
options: textureOptions,
|
|
value: saved?.texture || '',
|
|
};
|
|
}
|
|
|
|
const dialog = new Dialog({
|
|
id: 'edit_bone_texture_overrides',
|
|
title: 'Bone Texture Overrides',
|
|
width: 720,
|
|
form,
|
|
buttons: ['Cancel', 'Save & Apply'],
|
|
cancelIndex: 0,
|
|
confirmIndex: 1,
|
|
onConfirm: (result) => {
|
|
const out = {};
|
|
for (let i = 0; i < groups.length; i++) {
|
|
const group = groups[i];
|
|
const key = getGroupTextureOverrideKey(group);
|
|
const texture = String(result?.[`bone_texture_${i}`] || '').trim();
|
|
if (texture) {
|
|
out[key] = {
|
|
uuid: String(group.uuid || '').trim(),
|
|
name: String(group.name || '').trim(),
|
|
texture,
|
|
};
|
|
}
|
|
}
|
|
|
|
setProjectBoneTextureOverrides(out);
|
|
applyAllActorTexturePreviews(true);
|
|
return true;
|
|
},
|
|
});
|
|
|
|
dialog.show();
|
|
}
|
|
|
|
// ----------------------------
|
|
// Actor Textures
|
|
// ----------------------------
|
|
function getTextureDisplayName(texture) {
|
|
return String(texture?.name || texture?.id || texture?.uuid || '').trim();
|
|
}
|
|
|
|
function buildTextureSelectOptions(savedTextures, savedBoneTextureOverrides) {
|
|
const options = { '': '(none)' };
|
|
const add = (name) => {
|
|
const text = String(name || '').trim();
|
|
if (text && !Object.prototype.hasOwnProperty.call(options, text)) options[text] = text;
|
|
};
|
|
|
|
const list = Array.isArray(Texture.all) ? Texture.all : [];
|
|
for (const texture of list) add(getTextureDisplayName(texture));
|
|
for (const textureName of Object.values(savedTextures || {})) add(textureName);
|
|
for (const override of Object.values(savedBoneTextureOverrides || {})) add(override?.texture);
|
|
|
|
return options;
|
|
}
|
|
|
|
function findTextureByActorTextureName(name) {
|
|
const wanted = String(name || '').trim();
|
|
if (!wanted) return null;
|
|
|
|
const wantedLower = wanted.toLowerCase();
|
|
const list = Array.isArray(Texture.all) ? Texture.all : [];
|
|
|
|
return (
|
|
list.find((t) => String(t?.name || '').trim().toLowerCase() === wantedLower) ||
|
|
list.find((t) => String(t?.id || '').trim().toLowerCase() === wantedLower) ||
|
|
list.find((t) => String(t?.uuid || '').trim().toLowerCase() === wantedLower) ||
|
|
list.find((t) => String(t?.name || '').trim().toLowerCase().replace(/\.[^.]+$/, '') === wantedLower) ||
|
|
null
|
|
);
|
|
}
|
|
|
|
function groupBelongsToActor(group, actorIndex) {
|
|
const prefix = `actor${actorIndex}_`;
|
|
let g = group;
|
|
while (g) {
|
|
if (typeof g?.name === 'string' && g.name.startsWith(prefix)) return true;
|
|
g = g.parent instanceof Group ? g.parent : null;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getGroupsForTextureOverrides() {
|
|
const groups = Array.isArray(Group.all) ? Group.all.filter((group) => group && !isBlockRequirementGuide(group)) : [];
|
|
return groups.sort((a, b) => String(a?.name || '').localeCompare(String(b?.name || '')));
|
|
}
|
|
|
|
function getGroupTextureOverrideKey(group) {
|
|
return String(group?.uuid || group?.name || '').trim();
|
|
}
|
|
|
|
function getGroupTextureOverrideLabel(group) {
|
|
return String(group?.name || '(unnamed folder)').trim();
|
|
}
|
|
|
|
function getBoneTextureOverrideForGroup(group, overrides) {
|
|
if (!group || !overrides) return null;
|
|
|
|
const key = getGroupTextureOverrideKey(group);
|
|
const direct = key ? overrides[key] : null;
|
|
if (direct?.texture) return direct;
|
|
|
|
const name = String(group.name || '').trim();
|
|
if (!name) return null;
|
|
|
|
return Object.values(overrides).find((override) => override?.name === name && override?.texture) || null;
|
|
}
|
|
|
|
function cubeBelongsToActor(cube, actorIndex) {
|
|
const prefix = `actor${actorIndex}_`;
|
|
if (typeof cube?.name === 'string' && cube.name.startsWith(prefix)) return true;
|
|
return groupBelongsToActor(cube?.parent, actorIndex);
|
|
}
|
|
|
|
function getTextureMaterial(texture) {
|
|
if (!texture) return null;
|
|
if (typeof texture.getMaterial === 'function') return texture.getMaterial();
|
|
if (typeof texture.getOwnMaterial === 'function') return texture.getOwnMaterial();
|
|
return texture.material || null;
|
|
}
|
|
|
|
function forceActorTextureMaterial(cube, texture) {
|
|
const material = getTextureMaterial(texture);
|
|
if (!cube?.mesh || !material) return false;
|
|
|
|
cube.mesh.material = material;
|
|
cube.mesh.material.needsUpdate = true;
|
|
cube.mesh.needsUpdate = true;
|
|
cube.mesh.userData = cube.mesh.userData || {};
|
|
cube.mesh.userData.multiactor_actor_texture_uuid = texture.uuid || texture.id || texture.name;
|
|
return true;
|
|
}
|
|
|
|
function applyTextureToCube(cube, texture) {
|
|
if (!cube || !texture) return;
|
|
|
|
const actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name);
|
|
const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || texture.uv_width || 0;
|
|
const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || texture.uv_height || 0;
|
|
const uvChanged = actorIndex && imageWidth > 0 && imageHeight > 0
|
|
? normalizeCubeUvs(cube, actorIndex, imageWidth, imageHeight) : false;
|
|
|
|
const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
|
|
const textureId = texture.uuid || texture.id || texture.name;
|
|
|
|
let facesChanged = false;
|
|
if (textureId && cube.faces) {
|
|
for (const face of faces) {
|
|
const cubeFace = cube.faces[face];
|
|
if (!cubeFace) continue;
|
|
if (cubeFace.texture !== textureId) { cubeFace.texture = textureId; facesChanged = true; }
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
if (facesChanged || uvChanged) {
|
|
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.
|
|
const projectId = Project?.uuid;
|
|
setTimeout(() => { if (Project?.uuid === projectId) forceActorTextureMaterial(cube, texture); }, 0);
|
|
}
|
|
|
|
function applyActorTexturePreview(actorIndex, textureName) {
|
|
const texture = findTextureByActorTextureName(textureName);
|
|
if (!texture) return { applied: 0, missing: true };
|
|
|
|
let applied = 0;
|
|
const overrides = getProjectBoneTextureOverrides();
|
|
const cubes = Array.isArray(Cube.all) ? Cube.all : [];
|
|
for (const cube of cubes) {
|
|
if (!cubeBelongsToActor(cube, actorIndex)) continue;
|
|
const override = getNearestBoneTextureOverride(cube, overrides);
|
|
if (override?.texture && findTextureByActorTextureName(override.texture)) continue;
|
|
applyTextureToCube(cube, texture);
|
|
applied++;
|
|
}
|
|
|
|
return { applied, missing: false };
|
|
}
|
|
|
|
function getNearestBoneTextureOverride(cube, overrides) {
|
|
let group = cube?.parent instanceof Group ? cube.parent : null;
|
|
while (group) {
|
|
const override = getBoneTextureOverrideForGroup(group, overrides);
|
|
if (override?.texture) return override;
|
|
group = group.parent instanceof Group ? group.parent : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function applyBoneTextureOverrides() {
|
|
const overrides = getProjectBoneTextureOverrides();
|
|
const missing = [];
|
|
let applied = 0;
|
|
|
|
if (!Object.keys(overrides).length) return { applied, missing };
|
|
|
|
const textureByName = {};
|
|
const cubes = Array.isArray(Cube.all) ? Cube.all : [];
|
|
for (const cube of cubes) {
|
|
const override = getNearestBoneTextureOverride(cube, overrides);
|
|
const textureName = String(override?.texture || '').trim();
|
|
if (!textureName) continue;
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(textureByName, textureName)) {
|
|
textureByName[textureName] = findTextureByActorTextureName(textureName);
|
|
}
|
|
|
|
const texture = textureByName[textureName];
|
|
if (!texture) {
|
|
if (!missing.includes(textureName)) missing.push(textureName);
|
|
continue;
|
|
}
|
|
|
|
applyTextureToCube(cube, texture);
|
|
applied++;
|
|
}
|
|
|
|
return { applied, missing };
|
|
}
|
|
|
|
function applyAllActorTexturePreviews(showMessage = false) {
|
|
const textures = getProjectActorTextures();
|
|
const missing = [];
|
|
let applied = 0;
|
|
|
|
for (const [idxText, textureName] of Object.entries(textures)) {
|
|
const idx = parseInt(idxText, 10);
|
|
if (!Number.isFinite(idx) || idx <= 0) continue;
|
|
|
|
const result = applyActorTexturePreview(idx, textureName);
|
|
applied += result.applied || 0;
|
|
if (result.missing) missing.push(textureName);
|
|
}
|
|
|
|
const boneResult = applyBoneTextureOverrides();
|
|
applied += boneResult.applied || 0;
|
|
for (const textureName of boneResult.missing || []) {
|
|
if (!missing.includes(textureName)) missing.push(textureName);
|
|
}
|
|
|
|
Canvas.updateAll?.();
|
|
|
|
if (showMessage) {
|
|
if (missing.length) {
|
|
const missingMessage =
|
|
'Applied texture preferences where possible. These textures are not loaded in this project:' +
|
|
'\n\n' +
|
|
missing.map((x) => `- ${x}`).join('\n');
|
|
|
|
Blockbench.showMessageBox({
|
|
title: 'Actor Preferences',
|
|
message: missingMessage,
|
|
buttons: ['OK'],
|
|
});
|
|
} else {
|
|
Blockbench.showQuickMessage(`Applied texture preferences to ${applied} cubes`, 2500);
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearScheduledActorTexturePreviewApplies() {
|
|
for (const timeout of actorTexturePreviewTimeouts) clearTimeout(timeout);
|
|
actorTexturePreviewTimeouts = [];
|
|
}
|
|
|
|
function hasProjectActorTexturePreviews() {
|
|
return Object.keys(getProjectActorTextures()).length > 0 || Object.keys(getProjectBoneTextureOverrides()).length > 0;
|
|
}
|
|
|
|
function scheduleActorTexturePreviewApply() {
|
|
clearScheduledActorTexturePreviewApplies();
|
|
if (!Project || !hasProjectActorTexturePreviews()) return;
|
|
|
|
const projectId = Project.uuid;
|
|
// Texture load callbacks and project events schedule their own refreshes.
|
|
for (const delay of [0, 250]) {
|
|
const timeout = setTimeout(() => {
|
|
if (Project?.uuid === projectId && 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';
|
|
}
|
|
|
|
async function cemTemplateCatalog() {
|
|
if (typeof window === 'undefined' || typeof window.loadCEMTemplateModels !== 'function') {
|
|
throw new Error('Install and enable CEM Template Loader before creating a JEM model.');
|
|
}
|
|
if (typeof MinecraftEULA !== 'undefined' && !await MinecraftEULA.promptUser('cem_template_loader')) return null;
|
|
// The first loader call initializes its UI; subsequent calls return the shared catalog promise.
|
|
let catalog = await window.loadCEMTemplateModels();
|
|
if (!catalog?.models) catalog = await window.loadCEMTemplateModels();
|
|
if (!catalog?.models || !Array.isArray(catalog.entities)) {
|
|
throw new Error('CEM Template Loader did not expose a usable preset catalog. Update it or retry after its presets have loaded.');
|
|
}
|
|
return catalog;
|
|
}
|
|
|
|
function jemPresetParts(model) {
|
|
const parts = [];
|
|
function visit(node, name, path) {
|
|
parts.push({ name, path });
|
|
for (const [index, child] of (node.submodels || []).entries()) {
|
|
const childName = child.id || child.comment;
|
|
if (childName) visit(child, childName, `${path}/${index}`);
|
|
}
|
|
}
|
|
for (const [index, part] of (model.models || []).entries()) {
|
|
if (part.part) visit(part, part.part, String(index));
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function jemTransferSuggestions(groups, parts, actorIndex) {
|
|
const prefix = actorIndex ? `actor${actorIndex}_` : '';
|
|
const nameOf = (group) => prefix && group.name.startsWith(prefix) ? group.name.slice(prefix.length) : group.name;
|
|
const normalized = (name) => String(name).toLowerCase().replace(/[ _-]/g, '');
|
|
const match = (group) => {
|
|
const matches = parts.filter((part) => normalized(part.name) === normalized(nameOf(group)));
|
|
return matches.length === 1 ? matches[0] : null;
|
|
};
|
|
const groupSet = new Set(groups), containsPreset = new Map();
|
|
function hasPreset(group) {
|
|
if (!containsPreset.has(group)) containsPreset.set(group, !!match(group)
|
|
|| (group.children || []).some((child) => groupSet.has(child) && hasPreset(child)));
|
|
return containsPreset.get(group);
|
|
}
|
|
const additions = groups.filter((group) => !hasPreset(group)
|
|
&& (!groupSet.has(group.parent) || hasPreset(group.parent)));
|
|
return additions.map((root) => {
|
|
let ancestor = root.parent, mapped;
|
|
while (groupSet.has(ancestor)) {
|
|
mapped = match(ancestor);
|
|
if (mapped) break;
|
|
ancestor = ancestor.parent;
|
|
}
|
|
return { root, name: nameOf(root), target: mapped?.path || '',
|
|
sourceParent: groupSet.has(root.parent) ? root.parent : null, nameOf };
|
|
});
|
|
}
|
|
|
|
function jemAdditionBones(suggestion) {
|
|
const bones = [];
|
|
function visit(group, path) {
|
|
bones.push({ group, path, name: suggestion.nameOf(group) });
|
|
group.children.filter((child) => child instanceof Group).forEach((child, index) => visit(child, `${path}_${index}`));
|
|
}
|
|
visit(suggestion.root, 'root');
|
|
return bones;
|
|
}
|
|
|
|
function snapshotJemAddition(suggestion, result = {}, index = 0) {
|
|
const enabled = new Map(jemAdditionBones(suggestion).map((bone) => [bone.group,
|
|
!!result[`energy_${index}_${bone.path}`] && !jemEnergyInherited(bone.path, result, index)]));
|
|
const serialize = (group) => ({ name: suggestion.nameOf(group), origin: [...group.origin], rotation: [...group.rotation],
|
|
energyVisibility: enabled.get(group),
|
|
children: group.children.filter((child) => child instanceof Group || child instanceof Cube).map((child) =>
|
|
child instanceof Group ? { group: serialize(child) } : { cube: clone(child.getSaveCopy()) }) });
|
|
return { tree: serialize(suggestion.root), parentOrigin: [...(suggestion.sourceParent?.origin || [0, 0, 0])],
|
|
parentRotation: jemParentRotation(suggestion.sourceParent), rotationOrder: Format.euler_order || 'ZYX' };
|
|
}
|
|
|
|
function jemParentRotation(group) {
|
|
const chain = [];
|
|
while (group instanceof Group) { chain.unshift(group); group = group.parent; }
|
|
const rotation = new THREE.Quaternion();
|
|
for (const parent of chain) {
|
|
rotation.multiply(jemRotationQuaternion(parent.rotation, Format.euler_order || 'ZYX'));
|
|
}
|
|
return rotation.toArray();
|
|
}
|
|
|
|
function jemRotationQuaternion(rotation, order) {
|
|
return new THREE.Quaternion().setFromEuler(new THREE.Euler(
|
|
...rotation.map((angle) => angle * Math.PI / 180), order));
|
|
}
|
|
|
|
function jemTransferTransform(addition, parent) {
|
|
const targetOrder = Format.euler_order || 'ZYX';
|
|
const rotation = new THREE.Quaternion().fromArray(jemParentRotation(parent)).invert()
|
|
.multiply(new THREE.Quaternion().fromArray(addition.parentRotation));
|
|
const origin = new THREE.Vector3(...addition.tree.origin)
|
|
.sub(new THREE.Vector3(...addition.parentOrigin)).applyQuaternion(rotation)
|
|
.add(new THREE.Vector3(...parent.origin)).toArray();
|
|
const euler = new THREE.Euler().setFromQuaternion(rotation.multiply(
|
|
jemRotationQuaternion(addition.tree.rotation, addition.rotationOrder)), targetOrder);
|
|
return { offset: origin.map((value, axis) => value - addition.tree.origin[axis]),
|
|
rotation: [euler.x, euler.y, euler.z].map((angle) => angle * 180 / Math.PI) };
|
|
}
|
|
|
|
function jemEnergyInherited(path, result, index) {
|
|
let parent = path;
|
|
while (parent.includes('_')) {
|
|
parent = parent.slice(0, parent.lastIndexOf('_'));
|
|
if (result[`energy_${index}_${parent}`]) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function refreshJemEnergyFields(dialog, suggestions, result) {
|
|
suggestions.forEach((suggestion, index) => {
|
|
for (const bone of jemAdditionBones(suggestion)) {
|
|
const field = dialog.form?.form_data?.[`energy_${index}_${bone.path}`];
|
|
if (!field?.bar) continue;
|
|
const inherited = jemEnergyInherited(bone.path, result, index);
|
|
for (const input of field.bar.querySelectorAll('input')) input.disabled = inherited;
|
|
field.bar.style.opacity = inherited ? '0.4' : '';
|
|
field.bar.title = inherited ? 'Energy visibility is inherited from a selected parent bone.' : '';
|
|
}
|
|
});
|
|
}
|
|
|
|
function addJemEnergyVisibility(group) {
|
|
let root = group;
|
|
while (root.parent instanceof Group) root = root.parent;
|
|
if (!Array.isArray(root.cem_animations)) root.cem_animations = [];
|
|
root.cem_animations.push({ [`${group.name}.visible`]: 'nbt(NeedsOfNatureEnergy,range:70-200)' });
|
|
}
|
|
|
|
function validateJemChoices(suggestions, result) {
|
|
const selected = [];
|
|
suggestions.forEach((suggestion, index) => {
|
|
if (!result[`include_${index}`]) return;
|
|
const target = result[`parent_${index}`];
|
|
if (!target) throw new Error(`Choose a target parent for ${suggestion.name}, or uncheck that addition.`);
|
|
selected.push({ ...snapshotJemAddition(suggestion, result, index), target });
|
|
});
|
|
if (!selected.length) throw new Error('Select at least one added bone to transfer.');
|
|
return selected;
|
|
}
|
|
|
|
function createJemTransferProject(preset, additions, textures, baseTextureId, sourceUv, exportNow) {
|
|
const model = clone(preset.model);
|
|
// Texture pixels come from the source project, not remote template image URLs.
|
|
function stripTextures(node) {
|
|
delete node.texture;
|
|
for (const child of node.submodels || []) stripTextures(child);
|
|
}
|
|
stripTextures(model);
|
|
for (const part of model.models || []) stripTextures(part);
|
|
if (!newProject(Formats.optifine_entity)) throw new Error('Could not create the OptiFine project.');
|
|
Project.name = preset.name;
|
|
Formats.optifine_entity.codec.parse(model, '');
|
|
const targetGroups = Group.all.slice();
|
|
const destinations = new Map();
|
|
const roots = targetGroups.filter((group) => !(group.parent instanceof Group));
|
|
function collect(group, path) {
|
|
destinations.set(path, group);
|
|
group.children.filter((child) => child instanceof Group).forEach((child, index) => collect(child, `${path}/${index}`));
|
|
}
|
|
roots.forEach((group, index) => collect(group, String(index)));
|
|
const importedTextures = new Map();
|
|
for (const texture of textures) {
|
|
const data = { ...texture.data, name: texture.name, layers_enabled: false };
|
|
delete data.uuid; delete data.layers;
|
|
const copy = new Texture(data).fromDataURL(texture.image).add(false);
|
|
copy.use_as_default = texture.id === baseTextureId;
|
|
importedTextures.set(texture.id, copy);
|
|
}
|
|
const base = importedTextures.get(baseTextureId);
|
|
if (base) for (const group of targetGroups) group.texture = base.uuid;
|
|
const names = new Set(targetGroups.map((group) => group.name));
|
|
function uniqueName(name) {
|
|
let candidate = name, index = 2;
|
|
while (names.has(candidate)) candidate = `${name}_${index++}`;
|
|
names.add(candidate); return candidate;
|
|
}
|
|
for (const addition of additions) {
|
|
const parent = destinations.get(addition.target);
|
|
if (!parent) throw new Error(`The preset no longer contains parent ${addition.target}.`);
|
|
// Compensate only the subtree root; rotating its children again would double the transform.
|
|
const transform = jemTransferTransform(addition, parent);
|
|
const offset = transform.offset;
|
|
const shifted = (point) => point.map((value, axis) => value + offset[axis]);
|
|
const convertedRotation = (rotation) => {
|
|
if (addition.rotationOrder === (Format.euler_order || 'ZYX')) return rotation;
|
|
const euler = new THREE.Euler().setFromQuaternion(jemRotationQuaternion(rotation, addition.rotationOrder), Format.euler_order || 'ZYX');
|
|
return [euler.x, euler.y, euler.z].map((angle) => angle * 180 / Math.PI);
|
|
};
|
|
function copyTree(tree, destination) {
|
|
const group = new Group({ name: uniqueName(tree.name), origin: shifted(tree.origin),
|
|
rotation: tree === addition.tree ? transform.rotation : convertedRotation(tree.rotation) }).init();
|
|
group.addTo(destination);
|
|
if (tree.energyVisibility) addJemEnergyVisibility(group);
|
|
for (const child of tree.children) {
|
|
if (child.group) { copyTree(child.group, group); continue; }
|
|
const data = clone(child.cube);
|
|
delete data.uuid;
|
|
data.name = data.name.replace(/^actor\d+_/, '');
|
|
data.from = shifted(data.from); data.to = shifted(data.to);
|
|
if (data.origin) data.origin = shifted(data.origin);
|
|
if (data.rotation) data.rotation = convertedRotation(data.rotation);
|
|
const cube = new Cube(data).addTo(group).init();
|
|
// CEM supports one texture per group; create a dedicated attachment
|
|
// for each textured cube so differing source atlases stay independent.
|
|
const sourceId = Object.values(data.faces || {}).find((face) => typeof face.texture === 'string')?.texture || baseTextureId;
|
|
const texture = importedTextures.get(sourceId) || base;
|
|
if (texture) {
|
|
const textureGroup = new Group({ name: uniqueName(`${group.name}_texture`), origin: [...group.origin], texture: texture.uuid }).init();
|
|
textureGroup.addTo(group); cube.addTo(textureGroup);
|
|
if (cube.box_uv) cube.setUVMode(false);
|
|
for (const face of Object.values(cube.faces)) {
|
|
if (face.texture === null) continue;
|
|
face.texture = texture.uuid;
|
|
if (sourceUv.projectWide) {
|
|
face.uv = face.uv.map((value, axis) => value * (axis % 2 ? texture.uv_height / sourceUv.height : texture.uv_width / sourceUv.width));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
copyTree(addition.tree, parent);
|
|
}
|
|
Project.saved = false;
|
|
Canvas.updateAll();
|
|
if (exportNow) exportJemModel();
|
|
else Blockbench.showQuickMessage('Created JEM project with the selected added bones.', 3000);
|
|
}
|
|
|
|
function showJemTransferReview(catalog, actorIndex, entityId, sourceProject) {
|
|
const entity = catalog.entities.find((entry) => entry.id === entityId);
|
|
const raw = catalog.models[entity?.model || entityId]?.model;
|
|
if (!raw) throw new Error('The selected entity has no CEM preset.');
|
|
const model = typeof raw === 'string' ? JSON.parse(raw) : clone(raw);
|
|
const parts = jemPresetParts(model);
|
|
const groups = Group.all.filter((group) => (!actorIndex || readActorIndexFromName(group.name) === actorIndex) && !isBlockRequirementGuide(group));
|
|
const suggestions = jemTransferSuggestions(groups, parts, actorIndex);
|
|
if (!suggestions.length) throw new Error('No added bone subtrees were found compared with this preset. Modified vanilla cubes are not transferred.');
|
|
const form = { info: { type: 'info', text: '### Transfer added bones\nUnmatched bones are suggestions, not proof of new geometry. Uncheck renamed vanilla parts. The vanilla preset remains intact; animation keyframes and locators are not copied.' } };
|
|
const options = Object.fromEntries([['', 'Choose target parent'], ...parts.map((part) => [part.path, `${part.name} (${part.path})`])]);
|
|
suggestions.forEach((suggestion, index) => {
|
|
form[`section_${index}`] = { type: 'info', full_width: true, text: `${index ? '---\n' : ''}### ${suggestion.name}` };
|
|
form[`include_${index}`] = { type: 'checkbox', label: suggestion.name, value: true };
|
|
form[`parent_${index}`] = { type: 'select', label: `Parent for ${suggestion.name}`, options, value: suggestion.target,
|
|
condition: (result) => result[`include_${index}`] };
|
|
for (const bone of jemAdditionBones(suggestion)) {
|
|
form[`energy_${index}_${bone.path}`] = { type: 'checkbox', label: `Energy visibility: ${bone.name}`,
|
|
value: false, description: 'Show this bone only at NoN energy 70-200. Hiding a parent also hides its children.',
|
|
condition: (result) => result[`include_${index}`] };
|
|
}
|
|
});
|
|
let busy = false;
|
|
const dialog = new Dialog({ id: 'animationdirector_create_jem_review', title: 'Create JEM Model', width: 650, form,
|
|
buttons: ['Cancel', 'Create Project', 'Export Model'], cancelIndex: 0, confirmIndex: 1,
|
|
onFormChange(result) { refreshJemEnergyFields(dialog, suggestions, result); },
|
|
onButton(index) {
|
|
if (index !== 1 && index !== 2) return;
|
|
if (busy) return false;
|
|
try {
|
|
if (Project !== sourceProject) throw new Error('Return to the source project before transferring its geometry.');
|
|
const additions = validateJemChoices(suggestions, dialog.getFormResult());
|
|
let base = actorIndex ? findTextureByActorTextureName(getProjectActorTextures()[actorIndex]) : Texture.getDefault();
|
|
if (!base) {
|
|
const counts = new Map();
|
|
for (const cube of Cube.all) {
|
|
if (actorIndex && !cubeBelongsToActor(cube, actorIndex)) continue;
|
|
const texture = getCubeReadableTexture(cube);
|
|
if (texture) counts.set(texture, (counts.get(texture) || 0) + 1);
|
|
}
|
|
base = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
}
|
|
const ids = new Set(base ? [base.uuid] : []);
|
|
function textureIds(tree) {
|
|
for (const child of tree.children) {
|
|
if (child.group) { textureIds(child.group); continue; }
|
|
const cubeIds = new Set(Object.values(child.cube.faces || {}).map((face) => face.texture).filter((id) => typeof id === 'string'));
|
|
if (cubeIds.size > 1) throw new Error(`Cube ${child.cube.name} uses multiple textures. CEM requires one texture per cube; separate it before transferring.`);
|
|
for (const id of cubeIds) ids.add(id);
|
|
}
|
|
}
|
|
additions.forEach((addition) => textureIds(addition.tree));
|
|
const textures = [...ids].map((id) => {
|
|
const texture = findTextureById(id);
|
|
if (!texture || texture.error) throw new Error('A selected addition uses a missing texture. Load that texture before transferring.');
|
|
return { id, name: texture.name, data: texture.getSaveCopy(), image: texture.getDataURL() };
|
|
});
|
|
busy = true;
|
|
createJemTransferProject({ model, name: entity.file || entity.id }, additions, textures, base?.uuid,
|
|
{ width: Project.texture_width, height: Project.texture_height, projectWide: Format.id === 'geckolib_model' }, index === 2);
|
|
} catch (error) {
|
|
busy = false;
|
|
Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message });
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
dialog.show();
|
|
suggestions.forEach((suggestion, index) => {
|
|
const bar = dialog.form?.form_data?.[`section_${index}`]?.bar;
|
|
if (!bar) return;
|
|
bar.style.display = 'block';
|
|
const content = bar.querySelector('.small_text');
|
|
if (content) content.style.width = '100%';
|
|
const separator = bar.querySelector('hr');
|
|
if (separator) {
|
|
separator.style.border = '0';
|
|
separator.style.borderTop = '1px solid #777777';
|
|
separator.style.width = '100%';
|
|
}
|
|
});
|
|
}
|
|
|
|
async function showCreateJemModel() {
|
|
try {
|
|
if (!Project) throw new Error('Open a model project first.');
|
|
if (Format.id === 'optifine_entity') return exportJemModel();
|
|
if (!Formats.optifine_entity?.codec) throw new Error('The OptiFine Entity format is unavailable. Enable CEM Template Loader and retry.');
|
|
const sourceProject = Project;
|
|
const catalog = await cemTemplateCatalog();
|
|
if (!catalog) return;
|
|
if (Project !== sourceProject) throw new Error('The source project changed while loading the presets. Please retry.');
|
|
const actors = buildActorSelectOptions();
|
|
if (!Object.keys(actors).length) actors.model = 'Current model';
|
|
const first = Object.keys(actors)[0];
|
|
const entity = first === 'model' ? '' : actorAnimdefMetadataWithInference(Number(first), getProjectActorAnimdefMetadata()[first]).entity_types?.[0]?.split(':').pop();
|
|
const presets = Object.fromEntries(catalog.entities.filter((entry) => catalog.models[entry.model || entry.id]?.model)
|
|
.map((entry) => [entry.id, entry.name || entry.id]));
|
|
const dialog = new Dialog({ id: 'animationdirector_create_jem', title: 'Create JEM Model', form: {
|
|
actor: { type: 'select', label: 'Source actor', options: actors, value: first },
|
|
preset: { type: 'select', label: 'CEM entity preset', options: presets, value: presets[entity] ? entity : Object.keys(presets)[0] },
|
|
}, onConfirm(result) {
|
|
try {
|
|
if (Project !== sourceProject) throw new Error('Return to the source model project and retry.');
|
|
showJemTransferReview(catalog, result.actor === 'model' ? null : Number(result.actor), result.preset, sourceProject);
|
|
} catch (error) { Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); return false; }
|
|
} });
|
|
dialog.show();
|
|
} catch (error) { Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); }
|
|
}
|
|
|
|
function updateJemExportAction() {
|
|
if (!createJemModelAction) return;
|
|
const exporting = typeof Format !== 'undefined' && Format?.id === 'optifine_entity';
|
|
const name = exporting ? 'Export JEM Model' : 'Create JEM Model';
|
|
if (createJemModelAction.name !== name) {
|
|
if (typeof createJemModelAction.setName === 'function') createJemModelAction.setName(name);
|
|
else createJemModelAction.name = name;
|
|
}
|
|
createJemModelAction.description = exporting ? 'Export JEM gender variants with separate excluded-bone selections.'
|
|
: 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.';
|
|
}
|
|
|
|
function buildGenderModelVariant(model, hiddenBones) {
|
|
const output = clone(model);
|
|
for (const geometry of output['minecraft:geometry'] || []) {
|
|
const hidden = new Set(hiddenBones);
|
|
const bones = geometry.bones || [];
|
|
let previousSize;
|
|
do {
|
|
previousSize = hidden.size;
|
|
for (const bone of bones) if (hidden.has(bone.parent)) hidden.add(bone.name);
|
|
} while (hidden.size !== previousSize);
|
|
geometry.bones = bones.filter((bone) => !hidden.has(bone.name));
|
|
}
|
|
if (output.afw_bone_textures && hiddenBones.length) {
|
|
const retained = new Set((output['minecraft:geometry'] || []).flatMap((geometry) => geometry.bones || []).map((bone) => bone.name));
|
|
output.afw_bone_textures = Object.fromEntries(Object.entries(output.afw_bone_textures).filter(([name]) => retained.has(name)));
|
|
if (!Object.keys(output.afw_bone_textures).length) delete output.afw_bone_textures;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function jemVariantBones(model) {
|
|
const bones = [];
|
|
function collect(node, path, parent) {
|
|
bones.push({ name: path, parent, label: node.id || node.part || `Unnamed bone (${path})`, node });
|
|
(node.submodels || []).forEach((child, index) => collect(child, `${path}/${index}`, path));
|
|
if (node.submodel) collect(node.submodel, `${path}/single`, path);
|
|
}
|
|
(model.models || []).forEach((node, index) => collect(node, String(index), undefined));
|
|
return bones;
|
|
}
|
|
|
|
function buildJemModelVariant(model, hiddenBones) {
|
|
const output = clone(model);
|
|
const excluded = new Set(hiddenBones);
|
|
const removedIds = new Set();
|
|
function rememberIds(node) {
|
|
if (node.id) removedIds.add(node.id);
|
|
for (const child of node.submodels || []) rememberIds(child);
|
|
if (node.submodel) rememberIds(node.submodel);
|
|
}
|
|
function filter(node, path) {
|
|
if (excluded.has(path)) { rememberIds(node); return false; }
|
|
if (node.submodels) node.submodels = node.submodels.filter((child, index) => filter(child, `${path}/${index}`));
|
|
if (node.submodel && !filter(node.submodel, `${path}/single`)) delete node.submodel;
|
|
return true;
|
|
}
|
|
output.models = (output.models || []).filter((node, index) => filter(node, String(index)));
|
|
// Do not leave animation assignments targeting a removed model ID.
|
|
for (const { node } of jemVariantBones(output)) {
|
|
if (node.animations) node.animations = node.animations.map((animation) => Object.fromEntries(
|
|
Object.entries(animation).filter(([target]) => !removedIds.has(target.split('.')[0]))
|
|
)).filter((animation) => Object.keys(animation).length);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function hiddenModelAncestor(name, parents, selected) {
|
|
const visited = new Set([name]);
|
|
let parent = parents.get(name);
|
|
while (parent && !visited.has(parent)) {
|
|
if (selected.has(parent)) return parent;
|
|
visited.add(parent);
|
|
parent = parents.get(parent);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function refreshModelVariantFields(dialog, bones, result) {
|
|
const parents = new Map(bones.map((bone) => [bone.name, bone.parent]));
|
|
const updates = {};
|
|
for (const gender of ['m', 'f', 'mf']) {
|
|
const section = dialog.modelVariantBoneSections?.[gender];
|
|
if (section) section.style.display = result[gender] ? '' : 'none';
|
|
const selected = new Set(bones.filter((bone, index) => result[`hide_${gender}_${index}`]).map((bone) => bone.name));
|
|
bones.forEach((bone, index) => {
|
|
const key = `hide_${gender}_${index}`;
|
|
const ancestor = hiddenModelAncestor(bone.name, parents, selected);
|
|
if (ancestor && result[key]) result[key] = updates[key] = false;
|
|
const field = dialog.form?.form_data?.[key];
|
|
if (!field?.bar) return;
|
|
field.bar.style.opacity = ancestor ? '0.4' : '';
|
|
field.bar.title = ancestor ? `Already excluded by ${bones.find((entry) => entry.name === ancestor)?.label || ancestor}` : '';
|
|
for (const input of field.bar.querySelectorAll('input')) input.disabled = !!ancestor;
|
|
});
|
|
}
|
|
if (Object.keys(updates).length) dialog.form.setValues(updates, false);
|
|
}
|
|
|
|
function createModelVariantBoneSections(dialog, bones) {
|
|
dialog.modelVariantBoneSections = {};
|
|
for (const gender of ['m', 'f', 'mf']) {
|
|
const bars = bones.map((bone, index) => dialog.form?.form_data?.[`hide_${gender}_${index}`]?.bar).filter(Boolean);
|
|
const first = bars[0];
|
|
if (!first?.parentNode || !first.ownerDocument) continue;
|
|
const section = first.ownerDocument.createElement('div');
|
|
section.className = 'animationdirector_variant_bones';
|
|
section.style.maxHeight = '160px';
|
|
section.style.overflowY = 'auto';
|
|
section.style.overflowX = 'hidden';
|
|
section.style.border = '1px solid var(--color-border)';
|
|
section.style.padding = '4px 8px';
|
|
section.style.marginBottom = '12px';
|
|
first.parentNode.insertBefore(section, first);
|
|
// Keep the native form fields and their event handlers intact.
|
|
for (const bar of bars) section.appendChild(bar);
|
|
dialog.modelVariantBoneSections[gender] = section;
|
|
}
|
|
}
|
|
|
|
function exportGeckoModel() { exportModelVariants(false); }
|
|
|
|
function exportJemModel() { exportModelVariants(true); }
|
|
|
|
function exportModelVariants(jem) {
|
|
if (!Project) {
|
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const codec = jem ? Formats.optifine_entity?.codec : null;
|
|
if (jem && (!codec || Format.id !== 'optifine_entity')) throw new Error('Open an OptiFine Entity (JEM) project first.');
|
|
const output = jem ? parseCompiledModelContent(codec.compile(typeof codec.getExportOptions === 'function' ? codec.getExportOptions() : undefined))
|
|
: addAfwBoneTextureMapToGeckoJson(compileGeckoModelJson(), buildAfwBoneTextureMap());
|
|
if (jem && !Array.isArray(output.models)) throw new Error('The JEM compiler returned no model parts.');
|
|
const property = jem ? JEM_VARIANTS_PROPERTY : MODEL_VARIANTS_PROPERTY;
|
|
const title = jem ? 'Export JEM Models' : 'Export GeckoLib Models';
|
|
const extension = jem ? 'jem' : 'geo.json';
|
|
const suffix = (gender) => jem ? ({ m: '1', f: '2', mf: '3' })[gender] : `.${gender}`;
|
|
|
|
const sourceProject = Project;
|
|
let saved = {};
|
|
try { saved = JSON.parse(Project[property] || '{}'); } catch (_) { /* Use defaults for invalid saved options. */ }
|
|
const bones = jem ? jemVariantBones(output) : [...new Map((output['minecraft:geometry'] || [])
|
|
.flatMap((geometry) => geometry.bones || []).map((bone) => [bone.name, bone])).values()];
|
|
const form = {
|
|
name: { type: 'text', label: 'Base model filename', value: saved.name || (jem
|
|
? getModelExportFileName().replace(/\.jem$/i, '').replace(/[123]$/, '') : getModelExportFileName().replace(/\.(m|f|mf)$/i, '')) },
|
|
base: { type: 'checkbox', label: `Export unqualified model (.${extension})`, value: saved.base ?? true },
|
|
info: { type: 'info', text: 'Export one or more variants from this model. Excluded bones and their descendants are completely omitted, including geometry and locators. The source project is unchanged.' },
|
|
};
|
|
for (const [gender, label] of Object.entries({ m: 'Male', f: 'Female', mf: 'Male + female' })) {
|
|
form[`heading_${gender}`] = { type: 'info', text: `### ${label} (${suffix(gender)}.${extension})` };
|
|
form[gender] = { type: 'checkbox', label: `Export ${label.toLowerCase()}`, value: !!saved[gender] };
|
|
const hidden = Array.isArray(saved[`hide_${gender}`]) ? saved[`hide_${gender}`] : [];
|
|
form[`hide_heading_${gender}`] = { type: 'info', text: 'Exclude bones (children are automatically included):',
|
|
condition: (result) => !!result[gender] };
|
|
bones.forEach((bone, index) => {
|
|
form[`hide_${gender}_${index}`] = { type: 'checkbox', label: bone.label || bone.name,
|
|
value: hidden.includes(bone.name), condition: (result) => !!result[gender] };
|
|
});
|
|
}
|
|
const dialog = new Dialog({ id: 'animationdirector_model_variants', title, width: 650, form,
|
|
onFormChange(result) { refreshModelVariantFields(this, bones, result); },
|
|
onConfirm(result) {
|
|
try {
|
|
if (Project !== sourceProject) throw new Error('Return to the source model project and retry.');
|
|
const name = String(result.name || '').trim().replace(jem ? /\.jem$/i : /\.geo\.json$/i, '');
|
|
if (!name || /[<>:"/\\|?*]/.test(name)) throw new Error('Choose a valid base model filename without a folder path.');
|
|
const genders = ['m', 'f', 'mf'].filter((gender) => result[gender]);
|
|
if (!result.base && !genders.length) throw new Error('Select at least one model to export.');
|
|
const settings = { name, base: !!result.base };
|
|
const parents = new Map(bones.map((bone) => [bone.name, bone.parent]));
|
|
for (const gender of ['m', 'f', 'mf']) {
|
|
settings[gender] = !!result[gender];
|
|
const selected = new Set(bones.filter((bone, index) => result[`hide_${gender}_${index}`]).map((bone) => bone.name));
|
|
settings[`hide_${gender}`] = [...selected].filter((name) => !hiddenModelAncestor(name, parents, selected));
|
|
}
|
|
Project[property] = JSON.stringify(settings);
|
|
Project.saved = false;
|
|
const files = [...(result.base ? [{ name, model: output }] : []), ...genders.map((gender) => ({
|
|
name: `${name}${suffix(gender)}`, model: (jem ? buildJemModelVariant : buildGenderModelVariant)(output, settings[`hide_${gender}`]),
|
|
}))];
|
|
// Open sequential save dialogs rather than competing simultaneous dialogs.
|
|
const exportNext = (index) => {
|
|
if (index >= files.length) return;
|
|
const file = files[index];
|
|
Blockbench.export({ type: jem ? 'JEM Model' : 'GeckoLib Model JSON', extensions: jem ? ['jem'] : ['json', 'geo.json'],
|
|
name: jem ? file.name : `${file.name}.geo`, content: stringifyCompactModelJson(file.model, 2), savetype: 'text' },
|
|
() => exportNext(index + 1));
|
|
};
|
|
exportNext(0);
|
|
} catch (error) {
|
|
Blockbench.showMessageBox({ title, message: error.message });
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
dialog.show();
|
|
createModelVariantBoneSections(dialog, bones);
|
|
refreshModelVariantFields(dialog, bones, dialog.getFormResult());
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showMessageBox({
|
|
title: jem ? 'Export JEM Models' : 'Export GeckoLib Model',
|
|
message: `Export failed: ${e?.message || e}`,
|
|
buttons: ['OK'],
|
|
});
|
|
}
|
|
}
|
|
|
|
// ----------------------------
|
|
// Export Actor Animations
|
|
// ----------------------------
|
|
function getSelectedAnimation() {
|
|
return Animation.selected || Timeline.animation || Animation.all?.[0] || null;
|
|
}
|
|
|
|
function stripPrefixFromAnimationFileForActor(fileObj, actorIndex) {
|
|
const prefix = `actor${actorIndex}_`;
|
|
const anims = fileObj?.animations;
|
|
if (!anims) return;
|
|
|
|
for (const animKey of Object.keys(anims)) {
|
|
const anim = anims[animKey];
|
|
if (!anim) continue;
|
|
|
|
if (anim.bones) {
|
|
const newBones = {};
|
|
for (const [boneName, payload] of Object.entries(anim.bones)) {
|
|
if (boneName.startsWith(prefix)) newBones[boneName.slice(prefix.length)] = payload;
|
|
}
|
|
anim.bones = newBones;
|
|
}
|
|
|
|
// De-prefix common string fields (locators/particles)
|
|
const fixStrings = (node) => {
|
|
if (!node || typeof node !== 'object') return;
|
|
for (const k of Object.keys(node)) {
|
|
const v = node[k];
|
|
if (typeof v === 'string') {
|
|
if ((k === 'bone' || k === 'locator') && v.startsWith(prefix)) node[k] = v.slice(prefix.length);
|
|
} else if (v && typeof v === 'object') {
|
|
fixStrings(v);
|
|
}
|
|
}
|
|
};
|
|
fixStrings(anim);
|
|
}
|
|
}
|
|
|
|
function stringifyInlineVec3(obj, indent = 2) {
|
|
let i = 0;
|
|
const tokenPrefix = '__INLINE_VEC3__';
|
|
const replacements = new Map();
|
|
|
|
const json = JSON.stringify(
|
|
obj,
|
|
(key, value) => {
|
|
if (Array.isArray(value) && value.length === 3 && value.every((n) => typeof n === 'number' && Number.isFinite(n))) {
|
|
const token = `${tokenPrefix}${i++}__`;
|
|
replacements.set(token, `[${value[0]},${value[1]},${value[2]}]`);
|
|
return token;
|
|
}
|
|
return value;
|
|
},
|
|
indent
|
|
);
|
|
|
|
let out = json;
|
|
for (const [token, vecStr] of replacements) out = out.split(`"${token}"`).join(vecStr);
|
|
return out;
|
|
}
|
|
|
|
function exportAnimationOutputs(outputs, doneMessage) {
|
|
let idx = 0;
|
|
const next = () => {
|
|
if (idx >= outputs.length) {
|
|
Blockbench.showQuickMessage(doneMessage || `Exported ${outputs.length} actor animation files`, 2500);
|
|
return;
|
|
}
|
|
const o = outputs[idx++];
|
|
Blockbench.export(
|
|
{
|
|
type: 'JSON',
|
|
extensions: ['json'],
|
|
name: o.name,
|
|
content: o.content,
|
|
savetype: 'text',
|
|
},
|
|
next
|
|
);
|
|
};
|
|
next();
|
|
}
|
|
|
|
function parseAfwStageAnimationName(animation) {
|
|
const name = String(animation?.name || '').trim();
|
|
const match = /^([a-z0-9._-]+)\.p(\d+)$/.exec(name);
|
|
if (!match) return null;
|
|
return {
|
|
id: match[1],
|
|
stageNumber: Number(match[2]),
|
|
name,
|
|
animation,
|
|
};
|
|
}
|
|
|
|
function collectConjoinedAnimationGroups() {
|
|
const groupsById = new Map();
|
|
const ignored = [];
|
|
const animations = Array.isArray(Animation.all) ? Animation.all.filter((animation) => animation) : [];
|
|
for (const animation of animations) {
|
|
const stage = parseAfwStageAnimationName(animation);
|
|
if (!stage) {
|
|
ignored.push(String(animation?.name || 'unnamed'));
|
|
continue;
|
|
}
|
|
if (!groupsById.has(stage.id)) groupsById.set(stage.id, []);
|
|
groupsById.get(stage.id).push(stage);
|
|
}
|
|
|
|
const groups = Array.from(groupsById, ([id, stages]) => {
|
|
stages.sort((a, b) => a.stageNumber - b.stageNumber || a.name.localeCompare(b.name));
|
|
const seenStages = new Set();
|
|
for (const stage of stages) {
|
|
if (seenStages.has(stage.stageNumber)) {
|
|
throw new Error(`Animation "${id}" contains more than one .p${stage.stageNumber} stage.`);
|
|
}
|
|
seenStages.add(stage.stageNumber);
|
|
}
|
|
return { id, stages };
|
|
});
|
|
groups.sort((a, b) => a.id.localeCompare(b.id));
|
|
return { groups, ignored };
|
|
}
|
|
|
|
function previousStagePoseSource() {
|
|
if (typeof Format === 'undefined' || Format.id !== 'geckolib_model') return { reason: 'Open a GeckoLib animation project first.' };
|
|
const current = parseAfwStageAnimationName(Animation.selected);
|
|
if (!current) return { reason: 'Select an animation stage named animationID.pN.' };
|
|
const stages = getSavedAnimdef(current.id).stages || [];
|
|
const candidates = [...Animation.all.map(parseAfwStageAnimationName).filter((stage) => stage?.id === current.id),
|
|
...stages.map((stage) => ({ stageNumber: stage.stage }))].filter((stage) => stage.stageNumber < current.stageNumber);
|
|
if (!candidates.length) return { reason: 'This animation has no previous stage.' };
|
|
const previous = Math.max(...candidates.map((stage) => stage.stageNumber));
|
|
const playback = stages.find((stage) => stage.stage === previous)?.use_stage ?? previous;
|
|
const matches = Animation.all.filter((animation) => {
|
|
const stage = parseAfwStageAnimationName(animation);
|
|
return stage?.id === current.id && stage.stageNumber === playback;
|
|
});
|
|
if (matches.length !== 1) return { reason: matches.length ? 'The previous stage has duplicate animations.' : `Previous stage p${previous} uses missing animation p${playback}.` };
|
|
if (matches[0] === Animation.selected) return { reason: 'The previous stage reuses the current animation.' };
|
|
return { source: matches[0], previous };
|
|
}
|
|
|
|
function nextAutoplayStage(animation) {
|
|
const current = parseAfwStageAnimationName(animation);
|
|
if (!current) return null;
|
|
const stages = Animation.all.map(parseAfwStageAnimationName)
|
|
.filter((stage) => stage?.id === current.id && stage.stageNumber > current.stageNumber)
|
|
.sort((a, b) => a.stageNumber - b.stageNumber);
|
|
if (!stages.length || stages.filter((stage) => stage.stageNumber === stages[0].stageNumber).length !== 1) return null;
|
|
return stages[0].animation;
|
|
}
|
|
|
|
function updateAutoplayIconColor() {
|
|
if (!autoplayStagesAction) return;
|
|
const color = autoplayStagesAction.value ? '#000000' : '#b8a6cc';
|
|
autoplayStagesAction.color = color;
|
|
if (autoplayStagesAction.icon_node) autoplayStagesAction.icon_node.style.color = color;
|
|
for (const node of autoplayStagesAction.nodes || []) {
|
|
const icon = node.querySelector('.icon');
|
|
if (icon) icon.style.color = color;
|
|
}
|
|
}
|
|
|
|
function installStageAutoplay() {
|
|
autoplayLoopOriginal = Timeline.loop;
|
|
// Native playback resets once-clips before emitting pause. Observe the completed
|
|
// playback step instead of treating a user's pause as a stage transition.
|
|
autoplayLoopWrapper = function (...args) {
|
|
// start() invokes loop() immediately. Defer that nested step to the next
|
|
// normal playback frame, but render the new pose synchronously below.
|
|
if (autoplayTransitioning) return;
|
|
const animation = Animation.selected;
|
|
const eligible = autoplayStagesAction?.value && Format.id === 'geckolib_model'
|
|
&& Timeline.playing && animation?.length > 0 && animation.loop !== 'loop'
|
|
&& !(Timeline.custom_range?.[0] || Timeline.custom_range?.[1]);
|
|
const globalLoop = typeof BarItems !== 'undefined' ? BarItems.looped_animation_playback : null;
|
|
const savedLoop = globalLoop?.value;
|
|
let result;
|
|
try {
|
|
if (eligible && globalLoop) globalLoop.value = false;
|
|
result = autoplayLoopOriginal.apply(this, args);
|
|
} finally {
|
|
if (eligible && globalLoop) globalLoop.value = savedLoop;
|
|
}
|
|
if (eligible && autoplayStagesAction?.value && Animation.selected === animation && !Timeline.playing) {
|
|
const next = nextAutoplayStage(animation);
|
|
if (next) {
|
|
autoplayTransitioning = true;
|
|
try {
|
|
Timeline.setTime(0);
|
|
next.select();
|
|
Timeline.start();
|
|
Animator.preview(true);
|
|
} finally {
|
|
autoplayTransitioning = false;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
Timeline.loop = autoplayLoopWrapper;
|
|
}
|
|
|
|
function removeStageAutoplay() {
|
|
if (Timeline.loop === autoplayLoopWrapper) Timeline.loop = autoplayLoopOriginal;
|
|
}
|
|
|
|
function refreshPreviousStagePoseButton() {
|
|
if (!previousStagePoseAction) return;
|
|
const { reason } = previousStagePoseSource();
|
|
const description = reason || 'Copy the final transforms of all actors from the previous stage into time 0. Existing time-0 transforms are replaced.';
|
|
previousStagePoseAction.description = description;
|
|
for (const node of new Set([previousStagePoseAction.node, ...(previousStagePoseAction.nodes || [])].filter(Boolean))) {
|
|
node.classList.toggle('disabled', !!reason);
|
|
node.setAttribute('aria-disabled', String(!!reason));
|
|
if (node.style) { node.style.opacity = reason ? '0.35' : ''; node.style.filter = reason ? 'grayscale(1)' : ''; }
|
|
const label = node.querySelector('.tooltip_description');
|
|
if (label) label.textContent = description;
|
|
}
|
|
}
|
|
|
|
function loopPoseUnavailableReason() {
|
|
if (typeof Format === 'undefined' || Format.id !== 'geckolib_model') return 'Open a GeckoLib animation project first.';
|
|
const animation = Animation.selected;
|
|
if (!animation) return 'Select an animation clip first.';
|
|
if (!Number.isFinite(animation.length) || animation.length <= 0) return 'Set a positive animation clip length first.';
|
|
if (animation.loop !== 'loop') return 'Set this animation to Loop before copying its first pose to the end.';
|
|
return null;
|
|
}
|
|
|
|
function refreshLoopPoseButton() {
|
|
if (!loopPoseAction) return;
|
|
const reason = loopPoseUnavailableReason();
|
|
const description = reason || 'Copy the first pose of all actors to the clip endpoint for looping. Existing endpoint transforms are replaced.';
|
|
loopPoseAction.description = description;
|
|
for (const node of new Set([loopPoseAction.node, ...(loopPoseAction.nodes || [])].filter(Boolean))) {
|
|
node.classList.toggle('disabled', !!reason);
|
|
node.setAttribute('aria-disabled', String(!!reason));
|
|
if (node.style) { node.style.opacity = reason ? '0.35' : ''; node.style.filter = reason ? 'grayscale(1)' : ''; }
|
|
const label = node.querySelector('.tooltip_description');
|
|
if (label) label.textContent = description;
|
|
}
|
|
}
|
|
|
|
function copyFirstPoseToEnd() {
|
|
const reason = loopPoseUnavailableReason();
|
|
if (reason) { Blockbench.showQuickMessage(reason, 2500); return; }
|
|
const target = Animation.selected;
|
|
copyActorPose(target, 0, target, target.length, 'Copy first pose to clip end', true);
|
|
}
|
|
|
|
function copyPreviousStagePose() {
|
|
const { source, reason } = previousStagePoseSource();
|
|
if (reason) { Blockbench.showQuickMessage(reason, 2500); return; }
|
|
copyActorPose(source, source.length, Animation.selected, 0, 'Copy previous stage pose');
|
|
}
|
|
|
|
function copyActorPose(source, sourceTime, target, targetTime, label, keyedChannelsOnly = false) {
|
|
const poses = [];
|
|
const originalTime = Timeline.time;
|
|
try {
|
|
Timeline.time = sourceTime;
|
|
for (const group of Group.all) {
|
|
if (readActorIndexFromName(group.name) === null || isBlockRequirementGuide(group)) continue;
|
|
const animator = source.animators[group.uuid];
|
|
Animator.resetLastValues();
|
|
for (const channel of ['position', 'rotation', 'scale']) {
|
|
if (keyedChannelsOnly && !animator?.[channel]?.length) continue;
|
|
const value = animator?.[channel]?.length ? animator.interpolate(channel, false) : null;
|
|
const values = value || (channel === 'scale' ? [1, 1, 1] : [0, 0, 0]);
|
|
if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error(`Cannot evaluate ${group.name} ${channel} at time ${sourceTime}.`);
|
|
poses.push({ group, channel, values });
|
|
}
|
|
}
|
|
} finally {
|
|
Timeline.time = originalTime;
|
|
Animator.resetLastValues();
|
|
}
|
|
if (!poses.length) { Blockbench.showQuickMessage('There are no actor transforms to copy.', 2500); return; }
|
|
const keyframes = poses.flatMap(({ group, channel }) => (target.animators[group.uuid]?.[channel] || []).filter((frame) => frame.time === targetTime));
|
|
Undo.initEdit({ animations: [target], keyframes });
|
|
for (const { group, channel, values } of poses) {
|
|
const animator = target.getBoneAnimator(group);
|
|
const frame = animator[channel].find((frame) => frame.time === targetTime);
|
|
const data = { interpolation: 'linear', uniform: false, data_points: [{ x: values[0], y: values[1], z: values[2] }] };
|
|
if (frame) frame.extend(data);
|
|
// Preserve the exact clip endpoint, even when it falls between timeline grid steps.
|
|
else keyframes.push(animator.addKeyframe({ ...data, channel, time: targetTime }));
|
|
}
|
|
Undo.finishEdit(label, { animations: [target], keyframes });
|
|
Animator.preview();
|
|
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
|
Blockbench.showQuickMessage(`Copied actor pose to time ${targetTime}.`, 2500);
|
|
}
|
|
|
|
function cleanEffectPreviewFields(clip) {
|
|
for (const cues of Object.values(clip?.sound_effects || {})) {
|
|
for (const cue of Array.isArray(cues) ? cues : [cues]) {
|
|
if (!cue || typeof cue !== 'object') continue;
|
|
delete cue.file; delete cue[SOUND_PREVIEW_PROPERTY];
|
|
}
|
|
}
|
|
for (const cues of Object.values(clip?.particle_effects || {})) {
|
|
for (const cue of Array.isArray(cues) ? cues : [cues]) {
|
|
if (!cue || typeof cue !== 'object') continue;
|
|
delete cue.file; delete cue[PARTICLE_PREVIEW_PROPERTY];
|
|
}
|
|
}
|
|
return clip;
|
|
}
|
|
|
|
function compileConjoinedStage(stage) {
|
|
const animation = stage?.animation;
|
|
if (typeof animation?.compileBedrockAnimation !== 'function') {
|
|
throw new Error(`Animation compiler not available for "${stage?.name || 'animation'}".`);
|
|
}
|
|
|
|
const compiled = animation.compileBedrockAnimation() || {};
|
|
if (!compiled.animations) {
|
|
return { formatVersion: '1.8.0', clip: cleanEffectPreviewFields(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: cleanEffectPreviewFields(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(
|
|
loop ? result?.[`${base}_midpoint_offset`] : '',
|
|
`Stage p${stage.stageNumber} midpoint offset`,
|
|
() => true
|
|
);
|
|
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(
|
|
loop ? result?.[`${base}_duration_multiplier`] : '',
|
|
`Stage p${stage.stageNumber} duration multiplier`,
|
|
(value) => value >= 0.05 && value <= 20
|
|
);
|
|
const useStage = parseOptionalNumber(
|
|
result?.[`${base}_use_stage`] === 'own' ? '' : 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 (loop && 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 (loop && durationMultiplier !== null) output.stage_duration_multiplier = durationMultiplier;
|
|
return output;
|
|
}
|
|
|
|
function saveAnimationDefinitionFields(group, definition, actorMetadata) {
|
|
const undo = typeof Undo !== 'undefined';
|
|
if (undo) Undo.initEdit({ animdef_edit: true, actor_preferences_edit: true });
|
|
const mergedMetadata = { ...getProjectActorAnimdefMetadata(), ...actorMetadata };
|
|
setProjectActorAnimdefMetadata(mergedMetadata);
|
|
setProjectAnimdefAnimators(definition.animators || []);
|
|
saveAnimdef(group.id, definition);
|
|
if (undo) Undo.finishEdit('Save animation definition');
|
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
|
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
|
|
}
|
|
|
|
function exportAnimationDefinitionStub(group, definition, actorMetadata) {
|
|
saveAnimationDefinitionFields(group, definition, actorMetadata);
|
|
Blockbench.export(
|
|
{
|
|
type: 'AFW Animation Definition',
|
|
extensions: ['json'],
|
|
name: sanitizeFilePart(group.id),
|
|
content: JSON.stringify(definition, null, 2),
|
|
savetype: 'text',
|
|
},
|
|
() =>
|
|
Blockbench.showQuickMessage(
|
|
`Created ${group.id}.json for data/<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));
|
|
for (const stage of definition.stages) {
|
|
if (stage.props && typeof stage.props === 'object' && !Array.isArray(stage.props)) {
|
|
stage.props = Object.fromEntries(Object.entries(stage.props).map(([label, value]) => [labelMap.get(label) || label, value]));
|
|
}
|
|
}
|
|
if (definition.position_anchor_actor !== undefined) {
|
|
if (!labelMap.has(definition.position_anchor_actor)) throw new Error('The position anchor references an unknown actor.');
|
|
definition.position_anchor_actor = labelMap.get(definition.position_anchor_actor);
|
|
}
|
|
return definition;
|
|
}
|
|
|
|
function applyAnimationDefinitionImport(group, analysis, source, sourcePaths = []) {
|
|
const definition = prepareAnimationDefinitionImport(source, analysis);
|
|
let entry = null;
|
|
if (definition.block_requirements) {
|
|
const req = definition.block_requirements;
|
|
if (!['wall', 'center_support'].includes(req.type)
|
|
|| (req.facing && !['front', 'back', 'left', 'right'].includes(req.facing))
|
|
|| (req.support && !['full_block', 'slab', 'half_height', 'surface'].includes(req.support))
|
|
|| (req.placement && !['directional_clearance', 'surface_footprint'].includes(req.placement))) {
|
|
throw new Error('The block requirement has an unsupported type, support, placement or facing.');
|
|
}
|
|
const form = buildBlockRequirementForm(group.id, req);
|
|
const values = Object.fromEntries(Object.entries(form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value]));
|
|
entry = readBlockRequirementForm(values);
|
|
buildBlockRequirementGuideBoxes(entry);
|
|
}
|
|
const props = importPropPreviews(group.id, definition, sourcePaths, readPropPreviews());
|
|
cancelBlockRequirementUpdate();
|
|
rebuildBlockRequirementGuides(group.id, entry, definition, true, props.entries);
|
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
|
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
|
|
if (props.warnings.length) Blockbench.showQuickMessage(`Item props: ${props.warnings.join(' ')}`, 6000);
|
|
void refreshPropPreviews(true);
|
|
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id);
|
|
showAnimationDefinitionReviewDialog(group, analysis);
|
|
}
|
|
|
|
function importAnimationDefinition() {
|
|
if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500);
|
|
const projectId = Project.uuid;
|
|
Filesystem.importFile({ type: 'AFW Animation Definition', extensions: ['json'], multiple: false,
|
|
readtype: 'text', title: 'Import Animation Definition' }, (files) => {
|
|
if (!files?.length) return;
|
|
const report = (error) => Blockbench.showMessageBox({ title: 'Import Animation Definition', message: error.message, buttons: ['OK'] });
|
|
try {
|
|
if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.');
|
|
const source = JSON.parse(String(files[0].content || '').replace(/^\uFEFF/, ''));
|
|
const groups = collectConjoinedAnimationGroups().groups;
|
|
if (!groups.length) throw new Error('Import your animation assets first (named <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, [files[0].path]);
|
|
return true;
|
|
} catch (e) { report(e); return false; }
|
|
},
|
|
}).show();
|
|
} catch (e) { report(e); }
|
|
});
|
|
}
|
|
|
|
function showAnimationDefinitionReviewDialog(group, analysis) {
|
|
const savedMetadata = getProjectActorAnimdefMetadata();
|
|
const saved = getSavedAnimdef(group.id);
|
|
const savedActors = new Map((Array.isArray(saved.actors) ? saved.actors : []).map((actor) => [actor.label, actor]));
|
|
const savedStages = new Map((Array.isArray(saved.stages) ? saved.stages : []).map((stage) => [stage.stage, stage]));
|
|
const editableStages = new Map(analysis.stages.map((stage) => [stage.stageNumber, stage]));
|
|
const reusedStages = [...savedStages.values()].filter((stage) => !editableStages.has(stage.stage)
|
|
&& editableStages.has(stage.use_stage)).map((stage) => ({ ...editableStages.get(stage.use_stage),
|
|
stageNumber: stage.stage, loop: stage.loop ?? editableStages.get(stage.use_stage).loop }));
|
|
analysis = { ...analysis, stages: [...analysis.stages, ...reusedStages].sort((a, b) => a.stageNumber - b.stageNumber) };
|
|
const savedSystemTags = uniqueTrimmedStrings((Array.isArray(saved.system_tags) ? saved.system_tags : [])
|
|
.map((tag) => String(tag).toLowerCase()));
|
|
const savedTag = savedSystemTags[0] || '';
|
|
const selectedSystemTag = Object.hasOwn(SYSTEM_TAG_OPTIONS, savedTag) ? savedTag : 'other';
|
|
const form = {
|
|
_info: {
|
|
type: 'info',
|
|
text:
|
|
`Creating ${group.id}.json with ${analysis.actorIndices.length} actor(s) and ${analysis.stages.length} stage(s). ` +
|
|
'Save stores your fields in this Blockbench project. Create Animdef also exports JSON for data/<namespace>/afw_animdefs/.',
|
|
},
|
|
};
|
|
if (analysis.warnings.length) {
|
|
form._warnings = {
|
|
type: 'info',
|
|
text: `Warnings: ${analysis.warnings.join(' ')}`,
|
|
};
|
|
}
|
|
if (savedSystemTags.length > 1) {
|
|
form._system_tag_warning = {
|
|
type: 'info',
|
|
text: `This saved definition has multiple system tags (${savedSystemTags.join(', ')}). ` +
|
|
'The generator now exports only the selected behavior, so review that choice before exporting.',
|
|
};
|
|
}
|
|
|
|
form._identity_separator = '_';
|
|
form._identity = { type: 'info', text: '### Identity and credits' };
|
|
form.display_name = {
|
|
label: 'Display Name',
|
|
type: 'text',
|
|
value: saved.display_name || '',
|
|
placeholder: formatAnimationDisplayName(group.id),
|
|
description: 'Optional player-facing name. AFW uses the animation ID when this is empty.',
|
|
};
|
|
|
|
form.animators = {
|
|
label: 'Animators',
|
|
type: 'text',
|
|
value: (saved.animators || (Object.keys(saved).length ? [] : getProjectAnimdefAnimators())).join(', '),
|
|
placeholder: 'Animator One, Animator Two',
|
|
description: 'Optional comma-separated individual animation credits. Saved in this Blockbench project.',
|
|
};
|
|
|
|
form._matching_separator = '_';
|
|
form._matching = { type: 'info', text: '### Matching & behavior' };
|
|
form.content_tags = {
|
|
label: 'Content tags', type: 'text', value: (saved.content_tags || []).join(', '),
|
|
placeholder: 'doggy, missionary',
|
|
description: 'Displayed to players and used for matching defeated or stuck follow-ups.',
|
|
};
|
|
form.liquid_gain_multiplier = {
|
|
label: 'Liquid gain multiplier', type: 'text', value: saved.liquid_gain_multiplier ?? '', placeholder: 'Default (1.0)',
|
|
description: 'Non-negative multiplier for liquid gained from this animation.',
|
|
};
|
|
form.attack_eligible = {
|
|
label: 'Attack eligible', type: 'checkbox', value: saved.attack_eligible !== false,
|
|
description: 'Turn off to exclude this animation from automatic mob attacks.',
|
|
};
|
|
const hasSystemTag = (result, tag) => result?.system_tags === tag;
|
|
const isTease = (result) => hasSystemTag(result, 'tease');
|
|
const isStuck = (result) => hasSystemTag(result, 'stuck');
|
|
form.system_tags = {
|
|
label: 'Behavior',
|
|
type: 'select',
|
|
options: SYSTEM_TAG_OPTIONS,
|
|
value: selectedSystemTag,
|
|
description: 'Choose one system tag. Tease and Stuck reveal their additional settings.',
|
|
};
|
|
form.other_system_tags = {
|
|
label: 'Other system tag', type: 'text', value: selectedSystemTag === 'other' ? savedTag : '',
|
|
placeholder: 'custom_tag',
|
|
description: 'One custom tag, included only while Other is selected.',
|
|
condition: (result) => hasSystemTag(result, 'other'),
|
|
};
|
|
form.stub_tease_multiplier = {
|
|
label: 'Nearby energy multiplier',
|
|
type: 'number',
|
|
value: saved.tease?.near_animation_energy_multiplier ?? 2,
|
|
min: 1,
|
|
max: 20,
|
|
step: 0.1,
|
|
condition: isTease,
|
|
};
|
|
form.manual_peak_priority = {
|
|
label: 'Manual peak priority', type: 'text', value: saved.manual_peak?.priority ?? '', placeholder: 'Default (0)',
|
|
description: 'Only used when the behavior is Manual peak. Higher values are selected first.',
|
|
condition: (result) => hasSystemTag(result, 'manualpeak'),
|
|
};
|
|
form.stub_stuck_initial_difficulty = {
|
|
label: 'Initial escape difficulty',
|
|
type: 'number',
|
|
value: saved.stuck?.initial_escape_difficulty_multiplier ?? 2,
|
|
min: 0.1,
|
|
max: 10,
|
|
step: 0.05,
|
|
condition: isStuck,
|
|
};
|
|
form.stub_stuck_final_difficulty = {
|
|
label: 'Final escape difficulty',
|
|
type: 'number',
|
|
value: saved.stuck?.final_escape_difficulty_multiplier ?? 0.75,
|
|
min: 0.1,
|
|
max: 10,
|
|
step: 0.05,
|
|
condition: isStuck,
|
|
};
|
|
form.stub_stuck_ease_delay = {
|
|
label: 'Ease delay seconds',
|
|
type: 'number',
|
|
value: saved.stuck?.ease_delay_seconds ?? 10,
|
|
min: 0,
|
|
max: 600,
|
|
step: 1,
|
|
condition: isStuck,
|
|
};
|
|
form.stub_stuck_ease_duration = {
|
|
label: 'Ease duration seconds',
|
|
type: 'number',
|
|
value: saved.stuck?.ease_duration_seconds ?? 30,
|
|
min: 0,
|
|
max: 600,
|
|
step: 1,
|
|
condition: isStuck,
|
|
};
|
|
form.stub_stuck_joined_difficulty = {
|
|
label: 'Joined escape difficulty',
|
|
type: 'number',
|
|
value: saved.stuck?.joined_escape_difficulty_multiplier ?? 1.5,
|
|
min: 0.1,
|
|
max: 10,
|
|
step: 0.05,
|
|
condition: isStuck,
|
|
};
|
|
form.stub_stuck_damage_behavior = {
|
|
label: 'Damage behavior',
|
|
type: 'select',
|
|
options: {
|
|
stop_on_damage: 'Stop animation on damage',
|
|
ignore_damage: 'Apply damage without stopping',
|
|
block_damage: 'Prevent damage',
|
|
},
|
|
value: saved.stuck?.damage_behavior || 'stop_on_damage',
|
|
condition: isStuck,
|
|
};
|
|
|
|
const showAdvancedMatching = !!(saved.required_union_tags?.length || saved.join_after?.length
|
|
|| saved.match_content_on_join || (saved.weight != null && saved.weight !== 1));
|
|
form.matching_advanced = {
|
|
label: 'Advanced', type: 'checkbox', style: 'toggle_switch', value: showAdvancedMatching,
|
|
description: 'Show additional matching and selection rules.',
|
|
};
|
|
const advancedMatching = (result) => result?.matching_advanced ?? showAdvancedMatching;
|
|
form.required_union_tags = {
|
|
label: 'Required union tags', type: 'text', value: (saved.required_union_tags || []).join(', '),
|
|
placeholder: 'some.tag',
|
|
description: 'All tags must exist among the selected actors combined.',
|
|
condition: advancedMatching,
|
|
};
|
|
form.join_after = {
|
|
label: 'Join after', type: 'text', value: (saved.join_after || []).join(', '),
|
|
placeholder: 'creator:previous_animation',
|
|
description: 'Optional comma-separated exact namespaced animation IDs.',
|
|
condition: advancedMatching,
|
|
};
|
|
form.match_content_on_join = {
|
|
label: 'Match content on join', type: 'checkbox', value: saved.match_content_on_join === true,
|
|
description: 'Successors joining this animation must share at least one content tag.',
|
|
condition: advancedMatching,
|
|
};
|
|
form.weight = {
|
|
label: 'Selection weight', type: 'text', value: saved.weight ?? '', placeholder: 'Default (1.0)',
|
|
description: 'Positive random-selection weight among otherwise eligible animations.',
|
|
condition: advancedMatching,
|
|
};
|
|
|
|
form._placement_separator = '_';
|
|
form._placement = { type: 'info', text: '### Playback and placement' };
|
|
const authoredRequirement = getAuthoredBlockRequirement(group.id);
|
|
const blockRequirement = authoredRequirement ? authoredRequirement.requirement : saved.block_requirements;
|
|
form._block_requirement = {
|
|
type: 'info',
|
|
text: blockRequirement
|
|
? `#### Block requirement\nIncluded automatically:\n\n\`\`\`json\n${JSON.stringify(blockRequirement, null, 2)}\n\`\`\`\nEdit using Tools > AnimationDirector Anims > Block Requirements.`
|
|
: '#### Block requirement\nNone. Use Tools > AnimationDirector Anims > Block Requirements to create one.',
|
|
};
|
|
form.base_speed = {
|
|
label: 'Base playback speed', type: 'text', value: saved.speed ?? '', placeholder: 'Default (1.0)',
|
|
description: 'Positive speed used unless an individual stage overrides it.',
|
|
};
|
|
form.water = {
|
|
label: 'Water placement', type: 'select', value: saved.water || 'none',
|
|
options: { none: 'No water requirement', surface: 'Water surface', underwater: 'Underwater' },
|
|
};
|
|
const anchorOptions = { '': 'Automatic anchor' };
|
|
for (const actorIndex of analysis.actorIndices) {
|
|
const label = analysis.actorLabels.get(actorIndex);
|
|
anchorOptions[label] = label;
|
|
}
|
|
if (saved.position_anchor_actor && !Object.hasOwn(anchorOptions, saved.position_anchor_actor)) {
|
|
anchorOptions[saved.position_anchor_actor] = `${saved.position_anchor_actor} (unavailable - replace)`;
|
|
}
|
|
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', `Own stage (p${stage.stageNumber})`],
|
|
...analysis.stages.map((availableStage) => [
|
|
String(availableStage.stageNumber), `Stage p${availableStage.stageNumber}`,
|
|
]),
|
|
]),
|
|
value: savedStage.use_stage == null ? 'own' : String(savedStage.use_stage),
|
|
description: 'Optional stage number whose GeckoLib clip should be played instead.',
|
|
condition: showAdvanced,
|
|
};
|
|
if (savedStage.use_stage != null && !Object.hasOwn(form[`${base}_use_stage`].options, String(savedStage.use_stage))) {
|
|
form[`${base}_use_stage`].options[String(savedStage.use_stage)] = `Stage p${savedStage.use_stage} (unavailable - replace)`;
|
|
}
|
|
form[`${base}_stage_seconds`] = {
|
|
label: 'Stage seconds',
|
|
type: 'text',
|
|
value: savedStage.stage_seconds ?? '',
|
|
placeholder: 'Use NoN settings',
|
|
condition: showAdvanced,
|
|
};
|
|
form[`${base}_midpoint_offset`] = {
|
|
label: 'Cycle midpoint offset seconds',
|
|
type: 'text',
|
|
value: savedStage.cycle_midpoint_offset_seconds ?? '',
|
|
placeholder: 'Default (0.0)',
|
|
condition: showAdvancedLoop,
|
|
};
|
|
form[`${base}_duration_multiplier`] = {
|
|
label: 'Stage duration multiplier',
|
|
type: 'text',
|
|
value: savedStage.stage_duration_multiplier ?? '',
|
|
placeholder: 'Default (1.0)',
|
|
condition: showAdvancedLoop,
|
|
};
|
|
});
|
|
|
|
const submit = (result, exportFile) => {
|
|
try {
|
|
const actorMetadata = {};
|
|
const actors = [];
|
|
const unresolvedLabels = [];
|
|
for (const actorIndex of analysis.actorIndices) {
|
|
const label = analysis.actorLabels.get(actorIndex);
|
|
const metadata = readActorAnimdefFormFields(result, 'stub_actor', actorIndex);
|
|
actorMetadata[String(actorIndex)] = metadata;
|
|
if (!metadata.entity_types?.length) unresolvedLabels.push(label);
|
|
actors.push({ ...unmanagedAnimdefFields(savedActors.get(label), ACTOR_GENERATOR_KEYS),
|
|
...buildActorConstraint(label, metadata) });
|
|
}
|
|
|
|
const stages = analysis.stages.map((stage, index) => ({
|
|
...unmanagedAnimdefFields(savedStages.get(stage.stageNumber), STAGE_GENERATOR_KEYS),
|
|
...readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1),
|
|
}));
|
|
const actorsByLabel = new Map(actors.map((actor) => [actor.label, actor]));
|
|
const explicitReceivers = actors.some((actor) => actor.receiver);
|
|
const inferredReceivers = actors.some((actor) => !actor.injector);
|
|
for (const actor of actors) {
|
|
const targets = typeof actor.injects_into === 'string'
|
|
? [actor.injects_into] : actor.injects_into || [];
|
|
for (const target of targets) {
|
|
const receiver = actorsByLabel.get(target);
|
|
if (!receiver || target === actor.label) {
|
|
throw new Error(`Actor "${actor.label}" has invalid injection target "${target}".`);
|
|
}
|
|
const canReceive = explicitReceivers ? receiver.receiver
|
|
: inferredReceivers ? !receiver.injector
|
|
: receiver.entity_types?.includes('minecraft:player');
|
|
if (!canReceive) throw new Error(`Actor "${target}" is not a receiver for "${actor.label}".`);
|
|
}
|
|
}
|
|
const stageNumbers = new Set(stages.map((stage) => stage.stage));
|
|
for (const stage of stages) {
|
|
if (stage.use_stage && !stageNumbers.has(stage.use_stage)) {
|
|
throw new Error(`Stage p${stage.stage} references missing playback stage p${stage.use_stage}.`);
|
|
}
|
|
}
|
|
const displayName = String(result?.display_name || '').trim();
|
|
const animators = normalizeAnimatorNames(String(result?.animators || '').split(','));
|
|
const definition = unmanagedAnimdefFields(saved, DEFINITION_GENERATOR_KEYS);
|
|
const authored = getAuthoredBlockRequirement(group.id);
|
|
const requirement = authored ? authored.requirement : saved.block_requirements;
|
|
if (requirement) definition.block_requirements = clone(requirement);
|
|
if (displayName) definition.display_name = displayName;
|
|
if (animators.length) definition.animators = animators;
|
|
const selectedTag = String(result?.system_tags || '');
|
|
const customTag = String(result?.other_system_tags || '').trim().toLowerCase();
|
|
if (selectedTag === 'other' && (!customTag || customTag.includes(','))) {
|
|
throw new Error('Enter one custom system tag or choose a different behavior.');
|
|
}
|
|
const systemTags = selectedTag === 'other' ? [customTag] : selectedTag ? [selectedTag] : [];
|
|
const contentTags = uniqueTrimmedStrings(parseCommaSeparatedStrings(result?.content_tags)
|
|
.map((tag) => tag.toLowerCase()));
|
|
const unionTags = parseCommaSeparatedStrings(result?.required_union_tags);
|
|
const joinAfter = parseCommaSeparatedStrings(result?.join_after);
|
|
for (const id of joinAfter) {
|
|
if (!RESOURCE_ID_PATTERN.test(id)) {
|
|
throw new Error(`Invalid join_after animation ID "${id}". Use namespace:path.`);
|
|
}
|
|
}
|
|
if (systemTags.length) definition.system_tags = systemTags;
|
|
if (contentTags.length) definition.content_tags = contentTags;
|
|
if (unionTags.length) definition.required_union_tags = unionTags;
|
|
if (joinAfter.length) definition.join_after = joinAfter;
|
|
if (result?.match_content_on_join) {
|
|
if (!contentTags.length) throw new Error('Match content on join requires at least one content tag.');
|
|
definition.match_content_on_join = true;
|
|
}
|
|
const weight = parseOptionalNumber(result?.weight, 'Selection weight', (value) => value > 0);
|
|
if (weight !== null && weight !== 1) definition.weight = weight;
|
|
if (result?.attack_eligible === false) definition.attack_eligible = false;
|
|
const baseSpeed = parseOptionalNumber(result?.base_speed, 'Base playback speed', (value) => value > 0);
|
|
if (baseSpeed !== null && baseSpeed !== 1) definition.speed = baseSpeed;
|
|
const water = String(result?.water || 'none');
|
|
if (water !== 'none') definition.water = water;
|
|
const anchor = String(result?.position_anchor_actor || '');
|
|
if (anchor && !actorsByLabel.has(anchor)) throw new Error(`Position anchor "${anchor}" is unavailable. Choose another actor or Automatic anchor.`);
|
|
if (anchor) definition.position_anchor_actor = anchor;
|
|
const camera = unmanagedAnimdefFields(saved.camera, new Set(['torso_tracking', 'block_requirement_collision']));
|
|
if (result?.camera_torso_tracking === 'continuous') camera.torso_tracking = 'continuous';
|
|
if (result?.camera_block_collision === 'ignore') camera.block_requirement_collision = 'ignore';
|
|
if (Object.keys(camera).length) definition.camera = camera;
|
|
const liquidGain = parseOptionalNumber(result?.liquid_gain_multiplier,
|
|
'Liquid gain multiplier', (value) => value >= 0);
|
|
if (liquidGain !== null && liquidGain !== 1) definition.liquid_gain_multiplier = liquidGain;
|
|
if (systemTags.includes('manualpeak')) {
|
|
const priority = parseOptionalNumber(result?.manual_peak_priority,
|
|
'Manual peak priority', Number.isInteger);
|
|
const manualPeak = unmanagedAnimdefFields(saved.manual_peak, new Set(['priority']));
|
|
if (priority !== null && priority !== 0) manualPeak.priority = priority;
|
|
if (Object.keys(manualPeak).length) definition.manual_peak = manualPeak;
|
|
}
|
|
if (systemTags.includes('tease')) {
|
|
const multiplier = parseOptionalNumber(
|
|
result?.stub_tease_multiplier,
|
|
'Nearby energy multiplier',
|
|
(value) => value >= 1 && value <= 20
|
|
);
|
|
if (multiplier === null) throw new Error('Nearby energy multiplier is required.');
|
|
definition.tease = { ...saved.tease, near_animation_energy_multiplier: multiplier };
|
|
}
|
|
if (systemTags.includes('stuck')) {
|
|
if (!contentTags.length) {
|
|
throw new Error('A stuck animation needs at least one matching content tag.');
|
|
}
|
|
if (!stages.some((stage) => stage.escapable ?? true)) {
|
|
throw new Error('A stuck animation needs at least one escapable stage.');
|
|
}
|
|
const initial = parseOptionalNumber(
|
|
result?.stub_stuck_initial_difficulty,
|
|
'Initial escape difficulty',
|
|
(value) => value >= 0.1 && value <= 10
|
|
);
|
|
const finalDifficulty = parseOptionalNumber(
|
|
result?.stub_stuck_final_difficulty,
|
|
'Final escape difficulty',
|
|
(value) => value >= 0.1 && value <= 10
|
|
);
|
|
const delay = parseOptionalNumber(
|
|
result?.stub_stuck_ease_delay,
|
|
'Ease delay seconds',
|
|
(value) => Number.isInteger(value) && value >= 0 && value <= 600
|
|
);
|
|
const duration = parseOptionalNumber(
|
|
result?.stub_stuck_ease_duration,
|
|
'Ease duration seconds',
|
|
(value) => Number.isInteger(value) && value >= 0 && value <= 600
|
|
);
|
|
const joined = parseOptionalNumber(
|
|
result?.stub_stuck_joined_difficulty,
|
|
'Joined escape difficulty',
|
|
(value) => value >= 0.1 && value <= 10
|
|
);
|
|
if ([initial, finalDifficulty, delay, duration, joined].some((value) => value === null)) {
|
|
throw new Error('Every stuck behavior value is required.');
|
|
}
|
|
if (finalDifficulty > initial) {
|
|
throw new Error('Final escape difficulty cannot be harder than the initial difficulty.');
|
|
}
|
|
definition.stuck = {
|
|
...saved.stuck,
|
|
initial_escape_difficulty_multiplier: initial,
|
|
final_escape_difficulty_multiplier: finalDifficulty,
|
|
ease_delay_seconds: delay,
|
|
ease_duration_seconds: duration,
|
|
joined_escape_difficulty_multiplier: joined,
|
|
damage_behavior: String(result?.stub_stuck_damage_behavior || 'stop_on_damage'),
|
|
};
|
|
}
|
|
definition.actors = actors;
|
|
definition.stages = stages;
|
|
if (exportFile) {
|
|
confirmUnresolvedActorTypes(unresolvedLabels, () =>
|
|
exportAnimationDefinitionStub(group, definition, actorMetadata)
|
|
);
|
|
} else {
|
|
saveAnimationDefinitionFields(group, definition, actorMetadata);
|
|
Blockbench.showQuickMessage(`Saved animation definition fields for ${group.id}.`);
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
Blockbench.showMessageBox({
|
|
title: 'Animation Definition',
|
|
message: e?.message || String(e),
|
|
buttons: ['OK'],
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
const updateInjectorGenderDefault = createInjectorGenderDefaultHandler(form);
|
|
const dialog = new Dialog({
|
|
id: 'create_afw_animation_definition_review',
|
|
title: `Animation Definition: ${group.id}`,
|
|
width: 760,
|
|
form,
|
|
onFormChange(result) { updateInjectorGenderDefault(this, result); },
|
|
buttons: ['Cancel', 'Save', 'Create Animdef'],
|
|
cancelIndex: 0,
|
|
confirmIndex: 2,
|
|
onConfirm: (result) => submit(result, true),
|
|
onButton(index) {
|
|
if (index === 1) {
|
|
submit(dialog.getFormResult(), false);
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
dialog.show();
|
|
}
|
|
|
|
function showCreateAnimationDefinitionDialog() {
|
|
let detected;
|
|
try {
|
|
detected = collectConjoinedAnimationGroups();
|
|
} catch (e) {
|
|
Blockbench.showMessageBox({
|
|
title: 'Animation Definition',
|
|
message: e?.message || String(e),
|
|
buttons: ['OK'],
|
|
});
|
|
return;
|
|
}
|
|
if (!detected.groups.length) {
|
|
Blockbench.showMessageBox({
|
|
title: '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: '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: '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);
|
|
mapAnimationSounds(Animation.all);
|
|
mapAnimationParticles(Animation.all);
|
|
Blockbench.showQuickMessage(
|
|
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
|
|
2500
|
|
);
|
|
};
|
|
|
|
const existing = imported.stageNames.filter(animationNameExists);
|
|
if (existing.length) {
|
|
Blockbench.showMessageBox(
|
|
{
|
|
title: 'Import Animation',
|
|
message: `The following animations already exist:\n\n${existing.map((name) => `- ${name}`).join('\n')}\n\nOverwrite them?`,
|
|
buttons: ['Cancel', 'Overwrite'],
|
|
},
|
|
(button) => {
|
|
if (button !== 1 && button !== 'Overwrite') return;
|
|
for (const name of existing) removeAnimationByName(name);
|
|
doImport();
|
|
}
|
|
);
|
|
} else {
|
|
doImport();
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showMessageBox({
|
|
title: 'Import Animation',
|
|
message: `Import failed: ${e?.message || e}`,
|
|
buttons: ['OK'],
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
dialog.show();
|
|
} catch (e) {
|
|
console.error(e);
|
|
Blockbench.showMessageBox({
|
|
title: 'Import Animation',
|
|
message: `Import failed: ${e?.message || e}`,
|
|
buttons: ['OK'],
|
|
});
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function animationNameExists(name) {
|
|
const list = Animation.all;
|
|
if (!Array.isArray(list)) return false;
|
|
for (const a of list) if (a?.name === name) return true;
|
|
return false;
|
|
}
|
|
|
|
function removeAnimationByName(name) {
|
|
const list = Animation.all;
|
|
if (!Array.isArray(list)) return;
|
|
|
|
const a = list.find((x) => x?.name === name);
|
|
if (!a) return;
|
|
|
|
if (typeof a.remove === 'function') a.remove();
|
|
else if (typeof a.delete === 'function') a.delete();
|
|
}
|
|
|
|
// Automatic import reads only explicitly registered, extracted packs. No network access.
|
|
const PACK_FOLDERS_KEY = 'animationdirector_pack_folders';
|
|
const IMPORT_METADATA = [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, BONE_TEXTURE_OVERRIDES_PROPERTY,
|
|
ACTOR_ANIMDEF_METADATA_PROPERTY, ANIMDEF_ANIMATORS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, BLOCK_REQUIREMENTS_PROPERTY,
|
|
PROP_PREVIEWS_PROPERTY, ACTOR_UV_BASIS_PROPERTY];
|
|
|
|
// Item previews are private Three.js attachments, never actor geometry or animation tracks.
|
|
function propResourcePath(id, kind, extension) {
|
|
const value = String(id || '');
|
|
const resource = value.includes(':') ? value : `minecraft:${value}`;
|
|
if (!/^[a-z0-9_.-]+:[a-z0-9_./-]+$/.test(resource) || resource.split(':')[1].split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
throw new Error(`Invalid item resource ID: ${value}`);
|
|
}
|
|
const [namespace, name] = resource.split(':');
|
|
return `assets/${namespace}/${kind}/${name}${extension}`;
|
|
}
|
|
|
|
function indexPropZip(bytes) {
|
|
const data = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes), files = new Map();
|
|
if (data.length > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.');
|
|
let end = -1;
|
|
for (let offset = data.length - 22; offset >= Math.max(0, data.length - 65557); offset--) {
|
|
if (data.readUInt32LE(offset) === 0x06054b50 && offset + 22 + data.readUInt16LE(offset + 20) === data.length) { end = offset; break; }
|
|
}
|
|
if (end < 0) throw new Error('Not a supported ZIP/JAR archive.');
|
|
if (data.readUInt16LE(end + 4) || data.readUInt16LE(end + 6)) throw new Error('Split archives are unsupported.');
|
|
const count = data.readUInt16LE(end + 10), start = data.readUInt32LE(end + 16);
|
|
const limit = start + data.readUInt32LE(end + 12);
|
|
if (count === 65535 || start === 0xffffffff || limit > end) throw new Error('ZIP64 or invalid archive directory.');
|
|
let offset = start;
|
|
for (let index = 0; index < count; index++) {
|
|
if (offset + 46 > limit || data.readUInt32LE(offset) !== 0x02014b50) throw new Error('Invalid archive directory.');
|
|
const nameLength = data.readUInt16LE(offset + 28), extra = data.readUInt16LE(offset + 30), comment = data.readUInt16LE(offset + 32);
|
|
if (offset + 46 + nameLength + extra + comment > limit) throw new Error('Truncated archive directory.');
|
|
const name = data.toString('utf8', offset + 46, offset + 46 + nameLength);
|
|
if (name.startsWith('assets/') && !name.endsWith('/') && !name.includes('\\') && !name.split('/').includes('..')) {
|
|
files.set(name, { flags: data.readUInt16LE(offset + 8), method: data.readUInt16LE(offset + 10),
|
|
size: data.readUInt32LE(offset + 24), compressed: data.readUInt32LE(offset + 20), local: data.readUInt32LE(offset + 42) });
|
|
}
|
|
offset += 46 + nameLength + extra + comment;
|
|
}
|
|
return { files, read(name) {
|
|
const file = files.get(name);
|
|
if (!file) return null;
|
|
if (file.flags & 1 || file.size > 16 * 1024 * 1024) throw new Error('Encrypted or oversized asset entry.');
|
|
const local = file.local;
|
|
if (local + 30 > data.length || data.readUInt32LE(local) !== 0x04034b50) throw new Error('Invalid asset entry.');
|
|
const start = local + 30 + data.readUInt16LE(local + 26) + data.readUInt16LE(local + 28);
|
|
if (start + file.compressed > data.length) throw new Error('Truncated asset entry.');
|
|
const content = data.subarray(start, start + file.compressed);
|
|
let decoded;
|
|
if (file.method === 0) decoded = content;
|
|
else if (file.method === 8) {
|
|
const load = typeof requireNativeModule === 'function' ? requireNativeModule : require;
|
|
const zlib = load('zlib');
|
|
if (!zlib?.inflateRawSync) throw new Error('ZIP decompression is unavailable. Allow zlib access or use extracted assets.');
|
|
decoded = zlib.inflateRawSync(content, { maxOutputLength: 16 * 1024 * 1024 });
|
|
} else throw new Error('Unsupported asset compression method.');
|
|
if (decoded.length !== file.size) throw new Error('Asset entry size mismatch.');
|
|
return decoded;
|
|
} };
|
|
}
|
|
|
|
function openPropSource(file, io = desktopFiles()) {
|
|
const resolved = importFilePath(file, io), stats = io.fs.statSync(resolved);
|
|
const signature = `${resolved}:${stats.mtimeMs}:${stats.size}`;
|
|
if (propSources.has(signature)) return propSources.get(signature);
|
|
let source;
|
|
if (stats.isDirectory()) {
|
|
const root = io.path.basename(resolved) === 'assets' ? io.path.dirname(resolved) : resolved;
|
|
source = { read(name) {
|
|
const target = io.path.join(root, ...name.split('/'));
|
|
if (!io.fs.existsSync(target)) return null;
|
|
if (!insidePack(root, importFilePath(target, io), io)) throw new Error('Asset path escapes the selected folder.');
|
|
if (io.fs.statSync(target).size > 16 * 1024 * 1024) throw new Error('Asset exceeds the 16 MiB limit.');
|
|
return Buffer.from(io.fs.readFileSync(target));
|
|
}, itemIds() {
|
|
const ids = [], assets = io.path.join(root,'assets');
|
|
if (!io.fs.existsSync(assets)) return ids;
|
|
for (const namespace of io.fs.readdirSync(assets).slice(0,256)) {
|
|
const folder = io.path.join(assets,namespace,'items');
|
|
if (!io.fs.existsSync(folder)) continue;
|
|
const scan = (folder,prefix = '',depth = 0) => {
|
|
if (depth > 8 || ids.length >= 10000) return;
|
|
insidePack(root,folder,io);
|
|
for (const entry of io.fs.readdirSync(folder,{withFileTypes:true})) {
|
|
if (entry.isSymbolicLink()) continue;
|
|
if (entry.isDirectory()) scan(io.path.join(folder,entry.name),`${prefix}${entry.name}/`,depth+1);
|
|
else if (entry.name.endsWith('.json')) ids.push(`${namespace}:${prefix}${entry.name.slice(0,-5)}`);
|
|
if (ids.length >= 10000) break;
|
|
}
|
|
};
|
|
scan(folder);
|
|
}
|
|
return ids.sort();
|
|
} };
|
|
} else {
|
|
if (stats.size > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.');
|
|
source = indexPropZip(io.fs.readFileSync(resolved));
|
|
}
|
|
source.path = resolved;
|
|
// Keep only the most recently used archive rather than retaining several entire client JARs.
|
|
propSources.clear(); propSources.set(signature, source);
|
|
return source;
|
|
}
|
|
|
|
function discoverPropSources(io = desktopFiles()) {
|
|
const results = [];
|
|
const add = (file, label) => { if (io.fs.existsSync(file) && !results.some((entry) => entry.file === file)) results.push({ file, label }); };
|
|
if (typeof SystemInfo !== 'undefined' && SystemInfo.user_data_directory) {
|
|
const cache = io.path.join(SystemInfo.user_data_directory, 'minecraft_assets_cache');
|
|
if (io.fs.existsSync(cache)) for (const name of io.fs.readdirSync(cache).slice(0, 500)) {
|
|
if (/\.jar$/i.test(name)) add(io.path.join(cache, name), `Asset Browser: ${name.replace(/\.jar$/i, '')}`);
|
|
}
|
|
}
|
|
let minecraft = typeof settings !== 'undefined' ? settings.ewan_minecraft_directory?.value : null;
|
|
if (!minecraft && typeof SystemInfo !== 'undefined') {
|
|
if (SystemInfo.platform === 'win32' && SystemInfo.appdata_directory) minecraft = io.path.join(SystemInfo.appdata_directory, '.minecraft');
|
|
else if (SystemInfo.home_directory) minecraft = io.path.join(SystemInfo.home_directory,
|
|
...(SystemInfo.platform === 'darwin' ? ['Library', 'Application Support', 'minecraft'] : ['.minecraft']));
|
|
}
|
|
if (minecraft) {
|
|
const versions = io.path.join(minecraft, 'versions');
|
|
if (io.fs.existsSync(versions)) for (const name of io.fs.readdirSync(versions).slice(0, 500)) {
|
|
add(io.path.join(versions, name, `${name}.jar`), `Minecraft: ${name}`);
|
|
}
|
|
}
|
|
const last = typeof localStorage !== 'undefined' ? localStorage.getItem('animationdirector_prop_source') : null;
|
|
if (last) add(last, `Selected: ${io.path.basename(last)}`);
|
|
return results;
|
|
}
|
|
|
|
function propJson(source, path) {
|
|
const content = source.read(path);
|
|
return content ? JSON.parse(content.toString('utf8').replace(/^\uFEFF/, '')) : null;
|
|
}
|
|
|
|
function resolvePropModel(source, id, ancestors = new Set()) {
|
|
const path = propResourcePath(id, 'models', '.json');
|
|
if (ancestors.has(path) || ancestors.size >= 32) throw new Error(`Cyclic or excessive item model inheritance: ${id}`);
|
|
const json = propJson(source, path);
|
|
if (!json) throw new Error(`Missing item model: ${id}`);
|
|
ancestors = new Set(ancestors); ancestors.add(path);
|
|
let parent = {};
|
|
if (json.parent && !/^(minecraft:)?builtin\//.test(json.parent)) parent = resolvePropModel(source, json.parent, ancestors);
|
|
const result = { ...parent, ...json, textures: { ...parent.textures, ...json.textures }, display: { ...parent.display, ...json.display } };
|
|
result.generated = json.parent && /^(minecraft:)?builtin\/generated$/.test(json.parent) || parent.generated || false;
|
|
return result;
|
|
}
|
|
|
|
function propItemModels(node, warnings, depth = 0) {
|
|
if (!node || depth > 20) throw new Error('Missing or excessive item definition tree.');
|
|
const type = String(node.type || '').replace(/^minecraft:/, '');
|
|
if (type === 'model' && typeof node.model === 'string') {
|
|
if (node.tints?.length) warnings.push('This item uses component-dependent tinting; the preview uses its default tint.');
|
|
return [{ id: node.model, tints: node.tints || [] }];
|
|
}
|
|
if (type === 'composite') return (node.models || []).flatMap((child) => propItemModels(child, warnings, depth + 1));
|
|
if (['condition', 'select', 'range_dispatch'].includes(type)) {
|
|
warnings.push('Component-dependent item appearance: previewing its fallback/default branch.');
|
|
const child = type === 'condition' ? node.on_false : node.fallback;
|
|
if (!child) throw new Error('This conditional item has no default appearance. Use a manual texture preview.');
|
|
return propItemModels(child, warnings, depth + 1);
|
|
}
|
|
throw new Error(`Item renderer '${type || 'unknown'}' is not supported. Use a manual texture preview.`);
|
|
}
|
|
|
|
function propTextureId(value, textures) {
|
|
const seen = new Set();
|
|
while (typeof value === 'string' && value.startsWith('#')) {
|
|
if (seen.has(value)) throw new Error(`Cyclic item texture reference: ${value}`);
|
|
seen.add(value); value = textures[value.slice(1)];
|
|
}
|
|
if (typeof value !== 'string') throw new Error('Missing item texture reference.');
|
|
return value;
|
|
}
|
|
|
|
function propDisplayTransform(model, slot) {
|
|
const context = slot === 'floor' ? 'ground' : `thirdperson_${slot}hand`;
|
|
const explicit = model.display?.[context];
|
|
const data = explicit || (slot === 'left' ? model.display?.thirdperson_righthand : null) || {};
|
|
const rotation = v3(data.rotation), translation = v3(data.translation), scale = v3(data.scale, 1);
|
|
// Minecraft mirrors the selected display transform for left-hand rendering.
|
|
if (slot === 'left') {
|
|
translation[0] = -translation[0] || 0;
|
|
rotation[1] = -rotation[1] || 0;
|
|
rotation[2] = -rotation[2] || 0;
|
|
}
|
|
return { rotation, translation, scale };
|
|
}
|
|
|
|
function manualPropModel(preset) {
|
|
const generated = { generated: true, display: {
|
|
ground: { translation: [0, 2, 0], scale: [0.5, 0.5, 0.5] },
|
|
thirdperson_righthand: { translation: [0, 3, 1], scale: [0.55, 0.55, 0.55] },
|
|
} };
|
|
if (preset === 'handheld') generated.display = { ...generated.display,
|
|
thirdperson_righthand: { rotation: [0, -90, 55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] },
|
|
thirdperson_lefthand: { rotation: [0, 90, -55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] } };
|
|
return generated;
|
|
}
|
|
|
|
function loadPropAsset(source, item, slot) {
|
|
propResourcePath(item, 'items', '.json');
|
|
const warnings = [], definition = propJson(source, propResourcePath(item, 'items', '.json'));
|
|
const refs = definition ? propItemModels(definition.model, warnings) : [{ id: `${item.includes(':') ? item.split(':')[0] : 'minecraft'}:item/${item.split(':').pop()}`, tints: [] }];
|
|
const parts = [];
|
|
for (const ref of refs) {
|
|
const model = resolvePropModel(source, ref.id), transform = propDisplayTransform(model, slot);
|
|
const textures = {}, frames = {};
|
|
const addTexture = (value) => {
|
|
const id = propTextureId(value, model.textures);
|
|
const png = source.read(propResourcePath(id, 'textures', '.png'));
|
|
if (!png || png.length > 4 * 1024 * 1024) throw new Error(`Missing or oversized item texture: ${id}`);
|
|
textures[id] ||= `data:image/png;base64,${png.toString('base64')}`;
|
|
const metadata = propJson(source, propResourcePath(id, 'textures', '.png.mcmeta'));
|
|
if (metadata?.animation) {
|
|
warnings.push('Animated texture: the preview uses its first frame.');
|
|
frames[id] = metadata.animation;
|
|
}
|
|
return id;
|
|
};
|
|
if (model.generated) {
|
|
for (let layer = 0; layer < 5; layer++) {
|
|
if (!model.textures[`layer${layer}`]) continue;
|
|
const id = addTexture(model.textures[`layer${layer}`]);
|
|
const tint = ref.tints[layer];
|
|
const color = tint?.value ?? tint?.default;
|
|
parts.push({ generated: true, texture: id, png: textures[id], frame: frames[id], transform,
|
|
tint: typeof color === 'number' ? color & 0xffffff : 0xffffff });
|
|
}
|
|
} else if (Array.isArray(model.elements)) {
|
|
if (model.elements.length > 2048) throw new Error('Item model exceeds the preview element limit.');
|
|
const elements = clone(model.elements);
|
|
for (const element of elements) for (const face of Object.values(element.faces || {})) face.texture = addTexture(face.texture);
|
|
parts.push({ elements, textures, frames, transform,
|
|
tints: ref.tints.map((tint) => typeof (tint.value ?? tint.default) === 'number' ? (tint.value ?? tint.default) & 0xffffff : 0xffffff) });
|
|
} else throw new Error(`Model '${ref.id}' requires a special renderer. Use a manual texture preview.`);
|
|
}
|
|
if (!parts.length) throw new Error('The item model has no previewable geometry.');
|
|
return { parts, warnings: [...new Set(warnings)] };
|
|
}
|
|
|
|
function readPropPreviews() {
|
|
try { return JSON.parse(Project?.[PROP_PREVIEWS_PROPERTY] || '{}'); } catch (_) { return {}; }
|
|
}
|
|
|
|
function propEntryKey(animation, actor, slot, stage) { return JSON.stringify([animation, actor, slot, Number(stage)]); }
|
|
|
|
function effectivePropEntry(entries, animation, actor, slot, stage) {
|
|
return entries[propEntryKey(animation, actor, slot, stage)] ?? entries[propEntryKey(animation, actor, slot, 0)];
|
|
}
|
|
|
|
function effectivePropItem(definition, actor, slot, stageNumber) {
|
|
const field = `prop_${slot}`, stage = definition.stages?.find((stage) => stage.stage === stageNumber);
|
|
if (stage?.props === null || stage?.props?.[actor] === null) return null;
|
|
const props = stage?.props?.[actor];
|
|
if (props && Object.hasOwn(props, field)) return props[field];
|
|
return definition.actors?.find((entry) => entry.label === actor)?.[field];
|
|
}
|
|
|
|
function importPropPreviews(animation, definition, preferredSources = [], existing = {}) {
|
|
const entries = Object.fromEntries(Object.entries(existing).filter(([, entry]) => entry?.animation !== animation));
|
|
const warnings = [], requested = [];
|
|
for (const actor of definition.actors || []) for (const slot of Object.keys(PROP_SLOTS)) {
|
|
const base = effectivePropItem(definition, actor.label, slot, 0);
|
|
if (typeof base === 'string') requested.push({ actor: actor.label, slot, stage: 0, item: base });
|
|
for (const stage of definition.stages || []) {
|
|
const item = effectivePropItem(definition, actor.label, slot, stage.stage);
|
|
if (item === base) continue;
|
|
if (typeof item === 'string' || item === null && typeof base === 'string') {
|
|
requested.push({ actor: actor.label, slot, stage: stage.stage, item });
|
|
}
|
|
}
|
|
}
|
|
if (!requested.length) return { entries, warnings };
|
|
let io;
|
|
try { io = desktopFiles(); }
|
|
catch (_) { warnings.push('Preview assets require desktop Blockbench. Prop IDs remain in the imported definition.'); return { entries, warnings }; }
|
|
const paths = new Set();
|
|
const addPath = (path) => {
|
|
if (!path) return;
|
|
try {
|
|
const resolved = importFilePath(path, io);
|
|
if (io.fs.statSync(resolved).isDirectory() || /\.(?:jar|zip)$/i.test(resolved)) paths.add(resolved);
|
|
else paths.add(findImportPack(resolved, io).root);
|
|
} catch (_) { /* A standalone definition may have no parent pack. */ }
|
|
};
|
|
preferredSources.forEach(addPath);
|
|
try { savedPackFolders().forEach(addPath); } catch (_) { /* No registered packs. */ }
|
|
try { addPath(localStorage.getItem('animationdirector_prop_source')); } catch (_) { /* No saved source. */ }
|
|
try { discoverPropSources(io).forEach((source) => addPath(source.file)); } catch (_) { /* No client assets found. */ }
|
|
const sources = [];
|
|
for (const path of paths) {
|
|
try { sources.push({ path, data: openPropSource(path, io) }); }
|
|
catch (_) { /* Try the next available source. */ }
|
|
}
|
|
const resolved = new Map(), missing = new Set();
|
|
for (const prop of requested) {
|
|
const key = propEntryKey(animation, prop.actor, prop.slot, prop.stage);
|
|
if (prop.item === null) {
|
|
entries[key] = { animation, actor: prop.actor, slot: prop.slot, stage: prop.stage, mode: 'hidden' };
|
|
continue;
|
|
}
|
|
const assetKey = `${prop.slot}:${prop.item}`;
|
|
if (!resolved.has(assetKey)) {
|
|
let found = null;
|
|
for (const source of sources) {
|
|
try { found = { source: source.path, asset: loadPropAsset(source.data, prop.item, prop.slot) }; break; }
|
|
catch (_) { /* Item resources may live in another source. */ }
|
|
}
|
|
resolved.set(assetKey, found);
|
|
}
|
|
const found = resolved.get(assetKey);
|
|
if (!found) { missing.add(prop.item); continue; }
|
|
entries[key] = { animation, actor: prop.actor, slot: prop.slot, stage: prop.stage, mode: 'item',
|
|
item: prop.item, source: found.source, texture: '', preset: 'generated', asset: found.asset };
|
|
if (JSON.stringify(entries).length > 24 * 1024 * 1024) {
|
|
delete entries[key]; missing.add(prop.item);
|
|
}
|
|
}
|
|
if (missing.size) warnings.push(`No preview for ${[...missing].join(', ')}; choose an asset source in Item Props. The animdef items were preserved.`);
|
|
return { entries, warnings };
|
|
}
|
|
|
|
function writePropDefinition(definition, actor, slot, stage, mode, item) {
|
|
const result = clone(definition), field = `prop_${slot}`;
|
|
if (!['item','hidden','inherit'].includes(mode) || !Number.isInteger(Number(stage)) || Number(stage) < 0) throw new Error('Choose a valid prop behavior and stage.');
|
|
if (!PROP_SLOTS[slot] || !result.actors?.some((entry) => entry.label === actor)) throw new Error('Choose a valid actor and prop slot.');
|
|
if (mode === 'item') propResourcePath(item, 'items', '.json');
|
|
if (!stage) {
|
|
const entry = result.actors.find((entry) => entry.label === actor);
|
|
if (mode === 'item') entry[field] = item; else delete entry[field];
|
|
return result;
|
|
}
|
|
const entry = result.stages.find((entry) => entry.stage === Number(stage));
|
|
if (!entry) throw new Error(`Missing definition stage p${stage}.`);
|
|
// Expanding an explicit all-hidden override must not accidentally unhide other slots/actors.
|
|
if (entry.props === null) entry.props = Object.fromEntries(result.actors.map((entry) => [entry.label, null]));
|
|
entry.props ||= {};
|
|
if (entry.props[actor] === null) entry.props[actor] = { prop_left: null, prop_right: null, prop_floor: null };
|
|
entry.props[actor] ||= {};
|
|
if (mode === 'inherit') delete entry.props[actor][field];
|
|
else entry.props[actor][field] = mode === 'hidden' ? null : item;
|
|
if (!Object.keys(entry.props[actor]).length) delete entry.props[actor];
|
|
if (!Object.keys(entry.props).length) delete entry.props;
|
|
return result;
|
|
}
|
|
|
|
function propAuthoringDefinition(id) {
|
|
const group = collectConjoinedAnimationGroups().groups.find((group) => group.id === id);
|
|
if (!group) throw new Error('Select an animation with actor tracks first.');
|
|
const analysis = analyzeAnimationDefinitionGroup(group), saved = getSavedAnimdef(id);
|
|
const actors = analysis.actorIndices.map((index) => {
|
|
const label = analysis.actorLabels.get(index), old = saved.actors?.find((actor) => actor.label === label);
|
|
return { ...buildActorConstraint(label, getProjectActorAnimdefMetadata()[index] || {}), ...old, label };
|
|
});
|
|
const stages = saved.stages?.length ? saved.stages : analysis.stages.map((stage) => ({ stage: stage.stageNumber, loop: stage.loop, cycle_seconds: stage.cycleSeconds }));
|
|
return { ...saved, actors, stages };
|
|
}
|
|
|
|
async function decodePropPng(url, frame) {
|
|
if (typeof Image === 'undefined' || typeof document === 'undefined') throw new Error('Image previews require Blockbench.');
|
|
const image = await new Promise((resolve, reject) => {
|
|
const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Could not decode the preview PNG.')); image.src = url;
|
|
});
|
|
if (image.width > 1024 || image.height > 8192 || !image.width || !image.height) throw new Error('Preview PNG dimensions are unsupported.');
|
|
const width = frame?.width || image.width, height = frame ? frame.height || width : image.height;
|
|
const first = frame?.frames?.[0], index = typeof first === 'number' ? first : first?.index || 0;
|
|
const columns = Math.floor(image.width / width), x = (index % columns) * width, y = Math.floor(index / columns) * height;
|
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width * height > 65536 || !Number.isInteger(index)
|
|
|| index < 0 || width <= 0 || height <= 0 || x + width > image.width || y + height > image.height) {
|
|
throw new Error('Invalid or oversized animated texture frame.');
|
|
}
|
|
const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
|
|
const context = canvas.getContext('2d'); context.drawImage(image,x,y,width,height,0,0,width,height);
|
|
return { width, height, pixels: context.getImageData(0,0,width,height).data, canvas };
|
|
}
|
|
|
|
function extrudePropPixels(width, height, pixels) {
|
|
if (width * height > 65536) throw new Error('Extruded previews are limited to 65,536 pixels.');
|
|
const elements = [], solid = (x, y) => x >= 0 && y >= 0 && x < width && y < height && pixels[(y * width + x) * 4 + 3] > 0;
|
|
// Front/back use one alpha-cutout plane. Only silhouette edges need thickness geometry.
|
|
const faces = { north: { texture: 'sprite', uv: [16, 0, 0, 16] }, south: { texture: 'sprite', uv: [0, 0, 16, 16] } };
|
|
elements.push({ from: [0, 0, 7.5], to: [16, 16, 8.5], faces });
|
|
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) if (solid(x, y)) {
|
|
const edges = {};
|
|
const uv = [(x + 0.5) * 16 / width, (y + 0.5) * 16 / height];
|
|
const face = () => ({ texture: 'sprite', uv: [...uv, ...uv] });
|
|
if (!solid(x - 1, y)) edges.west = face();
|
|
if (!solid(x + 1, y)) edges.east = face();
|
|
if (!solid(x, y - 1)) edges.up = face();
|
|
if (!solid(x, y + 1)) edges.down = face();
|
|
if (Object.keys(edges).length) elements.push({ from: [x * 16 / width, 16 - (y + 1) * 16 / height, 7.5],
|
|
to: [(x + 1) * 16 / width, 16 - y * 16 / height, 8.5], faces: edges });
|
|
}
|
|
return elements;
|
|
}
|
|
|
|
function propFaceVertices(from, to, face) {
|
|
const [x, y, z] = from, [X, Y, Z] = to;
|
|
return { north: [[X,y,z],[x,y,z],[x,Y,z],[X,Y,z]], south: [[x,y,Z],[X,y,Z],[X,Y,Z],[x,Y,Z]],
|
|
west: [[x,y,z],[x,y,Z],[x,Y,Z],[x,Y,z]], east: [[X,y,Z],[X,y,z],[X,Y,z],[X,Y,Z]],
|
|
up: [[x,Y,Z],[X,Y,Z],[X,Y,z],[x,Y,z]], down: [[x,y,z],[X,y,z],[X,y,Z],[x,y,Z]] }[face];
|
|
}
|
|
|
|
function propDefaultUv(from, to, face) {
|
|
const [x,y,z] = from, [X,Y,Z] = to;
|
|
return { north: [16-X,16-Y,16-x,16-y], south: [x,16-Y,X,16-y], west: [z,16-Y,Z,16-y],
|
|
east: [16-Z,16-Y,16-z,16-y], up: [x,z,X,Z], down: [x,16-Z,X,16-z] }[face];
|
|
}
|
|
|
|
async function buildPropMesh(asset) {
|
|
const root = new THREE.Group(), images = new Map();
|
|
try {
|
|
for (const [partIndex, part] of asset.parts.entries()) {
|
|
const pngs = part.generated ? { sprite: part.png } : part.textures;
|
|
const partImages = new Map();
|
|
for (const [id, url] of Object.entries(pngs)) {
|
|
const frame = part.generated ? part.frame : part.frames?.[id], key = url + JSON.stringify(frame || null);
|
|
if (!images.has(key)) images.set(key, await decodePropPng(url, frame));
|
|
partImages.set(id, images.get(key));
|
|
}
|
|
const sprite = partImages.get('sprite');
|
|
const elements = part.generated ? extrudePropPixels(sprite.width, sprite.height, sprite.pixels) : part.elements;
|
|
const transform = new THREE.Group(); root.add(transform);
|
|
transform.position.fromArray(part.transform.translation);
|
|
transform.rotation.set(...part.transform.rotation.map((value) => value * Math.PI / 180), 'XYZ');
|
|
transform.scale.fromArray(part.transform.scale);
|
|
const centered = new THREE.Group(); centered.position.set(-8, -8, -8); transform.add(centered);
|
|
const buffers = new Map();
|
|
for (const element of elements) {
|
|
const from = v3(element.from), to = v3(element.to);
|
|
const rotation = element.rotation;
|
|
const matrix = new THREE.Matrix4();
|
|
if (rotation) {
|
|
if (!['x', 'y', 'z'].includes(rotation.axis)) throw new Error('Unsupported item element rotation axis.');
|
|
const pivot = v3(rotation.origin, 8), angle = num(rotation.angle) * Math.PI / 180;
|
|
const quaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(...['x','y','z'].map((axis) => axis === rotation.axis ? 1 : 0)), angle);
|
|
const scale = new THREE.Vector3(1,1,1);
|
|
if (rotation.rescale) for (const axis of ['x','y','z']) if (axis !== rotation.axis) scale[axis] = 1 / Math.cos(angle);
|
|
matrix.compose(new THREE.Vector3(...pivot), quaternion, scale).multiply(new THREE.Matrix4().makeTranslation(...pivot.map((value) => -value)));
|
|
}
|
|
for (const [side, face] of Object.entries(element.faces || {})) {
|
|
const vertices = propFaceVertices(from, to, side);
|
|
if (!vertices || !pngs[face.texture]) continue;
|
|
const tint = part.tint ?? part.tints?.[face.tintindex] ?? 0xffffff, key = `${face.texture}:${tint}`;
|
|
if (!buffers.has(key)) buffers.set(key, { texture: face.texture, tint, positions: [], uvs: [] });
|
|
const buffer = buffers.get(key), uv = face.uv || propDefaultUv(from,to,side);
|
|
const coords = [[uv[0],uv[3]],[uv[2],uv[3]],[uv[2],uv[1]],[uv[0],uv[1]]];
|
|
const turns = ((face.rotation || 0) / 90) % 4;
|
|
for (let count = 0; count < turns; count++) coords.unshift(coords.pop());
|
|
for (const index of [0,1,2,0,2,3]) {
|
|
const vector = new THREE.Vector3(...vertices[index]).applyMatrix4(matrix);
|
|
buffer.positions.push(vector.x,vector.y,vector.z);
|
|
buffer.uvs.push(coords[index][0]/16, 1-coords[index][1]/16);
|
|
}
|
|
}
|
|
}
|
|
for (const buffer of buffers.values()) {
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(buffer.positions,3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(buffer.uvs,2)); geometry.computeVertexNormals();
|
|
const texture = new THREE.CanvasTexture(partImages.get(buffer.texture).canvas);
|
|
texture.magFilter = THREE.NearestFilter; texture.minFilter = THREE.NearestFilter;
|
|
if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace;
|
|
else if (THREE.sRGBEncoding) texture.encoding = THREE.sRGBEncoding;
|
|
const material = new THREE.MeshLambertMaterial({ map: texture, side: THREE.DoubleSide, alphaTest: 0.01,
|
|
transparent: false, color: buffer.tint, polygonOffset: !!part.generated,
|
|
polygonOffsetFactor: -partIndex, polygonOffsetUnits: -partIndex });
|
|
centered.add(new THREE.Mesh(geometry, material));
|
|
}
|
|
}
|
|
return root;
|
|
} catch (error) { disposePropMesh(root); throw error; }
|
|
}
|
|
|
|
function disposePropMesh(root) {
|
|
root.parent?.remove(root);
|
|
root.traverse((node) => { node.geometry?.dispose(); if (node.material) { node.material.map?.dispose(); node.material.dispose(); } });
|
|
}
|
|
|
|
function clearPropMeshes() {
|
|
propPreviewGeneration++;
|
|
for (const mesh of propMeshes.values()) disposePropMesh(mesh);
|
|
propMeshes.clear(); propPreviewSignature = null;
|
|
}
|
|
|
|
async function refreshPropPreviews(force = false) {
|
|
const selected = typeof Animation !== 'undefined' ? parseAfwStageAnimationName(getSelectedAnimation()) : null;
|
|
const raw = Project?.[PROP_PREVIEWS_PROPERTY] || '{}';
|
|
if (raw === '{}' && !propMeshes.size) return;
|
|
const bones = typeof Group !== 'undefined' ? Group.all.filter((group) => /_prop(left|right|floor)$/.test(group.name)).map((group) => `${group.uuid}:${group.name}`).join(',') : '';
|
|
const definitions = Project?.[ANIMDEF_DEFINITIONS_PROPERTY];
|
|
const signature = { project: Project?.uuid, animation: selected?.name, raw, bones, definitions };
|
|
if (!force && signature.project === propPreviewSignature?.project && signature.animation === propPreviewSignature?.animation
|
|
&& raw === propPreviewSignature?.raw && bones === propPreviewSignature?.bones && definitions === propPreviewSignature?.definitions) {
|
|
for (const [uuid, mesh] of propMeshes) {
|
|
const bone = Group.all.find((group) => group.uuid === uuid);
|
|
if (bone?.mesh && mesh.parent !== bone.mesh) bone.mesh.add(mesh);
|
|
}
|
|
return;
|
|
}
|
|
clearPropMeshes(); propPreviewSignature = signature;
|
|
if (!Project || !selected || typeof THREE === 'undefined') return;
|
|
const generation = propPreviewGeneration, entries = readPropPreviews(), definition = getSavedAnimdef(selected.id);
|
|
for (const index of getSortedUsedActorIndicesFromProject()) for (const slot of Object.keys(PROP_SLOTS)) {
|
|
const actor = getActorExportLabel(index), entry = effectivePropEntry(entries, selected.id, actor, slot, selected.stageNumber);
|
|
if (!entry?.asset || entry.mode !== 'item') continue;
|
|
const item = effectivePropItem(definition, actor, slot, selected.stageNumber);
|
|
if (item === null) continue;
|
|
const bone = Group.all.find((group) => group.name === `actor${index}_${PROP_SLOTS[slot]}`);
|
|
if (!bone?.mesh) continue;
|
|
try {
|
|
const asset = item && item !== entry.item ? (!entry.texture && entry.source ? loadPropAsset(openPropSource(entry.source),item,slot) : null) : entry.asset;
|
|
if (!asset) continue;
|
|
const mesh = await buildPropMesh(asset);
|
|
if (generation !== propPreviewGeneration) { disposePropMesh(mesh); return; }
|
|
bone.mesh.add(mesh); propMeshes.set(bone.uuid, mesh);
|
|
} catch (error) { console.warn('Could not build item prop preview:', error); }
|
|
}
|
|
}
|
|
|
|
async function applyPropPreview(values) {
|
|
const projectId = Project?.uuid;
|
|
if (!projectId) throw new Error('Open an animation project first.');
|
|
const definition = propAuthoringDefinition(values.animation), stage = Number(values.stage), slot = values.slot, actor = values.actor;
|
|
const item = String(values.item || '').trim();
|
|
const updated = writePropDefinition(definition, actor, slot, stage, values.mode, item);
|
|
let asset = null;
|
|
if (values.mode === 'item') {
|
|
if (values.texture) {
|
|
const io = desktopFiles(), png = io.fs.readFileSync(values.texture);
|
|
if (png.length > 4 * 1024 * 1024) throw new Error('Manual texture exceeds the 4 MiB limit.');
|
|
asset = { parts: [{ generated: true, png: `data:image/png;base64,${Buffer.from(png).toString('base64')}`,
|
|
transform: propDisplayTransform(manualPropModel(values.preset),slot) }], warnings: [] };
|
|
} else {
|
|
if (!values.source) throw new Error('Select a Minecraft JAR/assets folder or a manual PNG texture.');
|
|
asset = loadPropAsset(openPropSource(values.source), item, slot);
|
|
}
|
|
// Validate image/geometry before changing project metadata or opening an undo edit.
|
|
const validation = await buildPropMesh(asset); disposePropMesh(validation);
|
|
}
|
|
if (Project?.uuid !== projectId) throw new Error('The active project changed. Try again.');
|
|
const key = propEntryKey(values.animation,actor,slot,stage), entries = readPropPreviews();
|
|
if (values.mode === 'inherit') delete entries[key];
|
|
else entries[key] = { animation: values.animation, actor, slot, stage, mode: values.mode, item,
|
|
source: values.source, texture: values.texture, preset: values.preset, asset };
|
|
const raw = JSON.stringify(entries);
|
|
if (raw.length > 24 * 1024 * 1024) throw new Error('Project prop previews exceed the 24 MiB embedded asset limit.');
|
|
Undo.initEdit({ prop_preview_edit: true, animdef_edit: true });
|
|
Project[PROP_PREVIEWS_PROPERTY] = raw; saveAnimdef(values.animation, updated);
|
|
Undo.finishEdit('Edit item prop', { prop_preview_edit: true, animdef_edit: true });
|
|
Project.saved = false;
|
|
await refreshPropPreviews(true);
|
|
return asset?.warnings || [];
|
|
}
|
|
|
|
function createPropDialog() {
|
|
const animationIds = collectConjoinedAnimationGroups().groups.map((group) => group.id);
|
|
const selected = parseAfwStageAnimationName(getSelectedAnimation());
|
|
const state = {
|
|
projectId: Project.uuid, animation: selected && animationIds.includes(selected.id) ? selected.id : animationIds[0] || '',
|
|
actor: '', slot: 'right', stage: '0', source: '', jar: '', folder: '', choice: '',
|
|
actors: [], stages: [], sources: discoverPropSources(), items: [], busy: false,
|
|
};
|
|
const addSource = (file) => {
|
|
if (file && !state.sources.some((entry) => entry.file === file)) state.sources.push({ file, label: `Selected: ${file}` });
|
|
};
|
|
const indexItems = () => {
|
|
state.items = [];
|
|
if (!state.source) return;
|
|
const source = openPropSource(state.source);
|
|
if (source.files) {
|
|
const modern = [...source.files.keys()].filter((name) => /^assets\/[^/]+\/items\/.+\.json$/.test(name));
|
|
state.items = modern.length ? modern.map((name) => name.replace(/^assets\/([^/]+)\/items\/(.+)\.json$/, '$1:$2')).sort()
|
|
: [...source.files.keys()].filter((name) => /^assets\/[^/]+\/models\/item\/.+\.json$/.test(name))
|
|
.map((name) => name.replace(/^assets\/([^/]+)\/models\/item\/(.+)\.json$/, '$1:$2')).sort();
|
|
} else state.items = source.itemIds();
|
|
};
|
|
const selectAnimation = () => {
|
|
const definition = state.animation ? propAuthoringDefinition(state.animation) : { actors: [], stages: [] };
|
|
state.actors = definition.actors.map((actor) => actor.label);
|
|
state.stages = definition.stages.map((stage) => stage.stage);
|
|
if (!state.actors.includes(state.actor)) state.actor = state.actors[0] || '';
|
|
if (!state.stages.includes(Number(state.stage))) state.stage = '0';
|
|
};
|
|
const savedEntry = () => {
|
|
const entry = readPropPreviews()[propEntryKey(state.animation, state.actor, state.slot, state.stage)];
|
|
const definition = getSavedAnimdef(state.animation), field = `prop_${state.slot}`;
|
|
const fallback = definition.actors?.find((actor) => actor.label === state.actor)?.[field];
|
|
const stage = definition.stages?.find((stage) => stage.stage === Number(state.stage));
|
|
const override = stage?.props?.[state.actor];
|
|
const value = Number(state.stage) ? (stage?.props === null || override === null ? null : override?.[field]) : fallback;
|
|
if (entry?.source) { addSource(entry.source); state.source = entry.source; }
|
|
return {
|
|
mode: entry?.mode || (value === null ? 'hidden' : value !== undefined ? 'item' : Number(state.stage) ? 'inherit' : 'item'),
|
|
item: entry?.item || value || fallback || 'minecraft:carrot',
|
|
texture: entry?.texture || '', preset: entry?.preset || 'generated',
|
|
};
|
|
};
|
|
selectAnimation();
|
|
state.source = localStorage.getItem('animationdirector_prop_source') || state.sources[0]?.file || '';
|
|
const initial = savedEntry();
|
|
addSource(state.source);
|
|
try { indexItems(); } catch (error) { console.warn('Could not index item assets:', error); state.items = []; }
|
|
const form = {
|
|
_target: { type: 'info', text: '### Prop target\nChoose the actor bone and the stages that should use this item.' },
|
|
animation: { label: 'Animation', type: 'select', value: state.animation,
|
|
options: Object.fromEntries(animationIds.map((id) => [id, id])) },
|
|
actor: { label: 'Actor', type: 'select', value: state.actor,
|
|
options: () => Object.fromEntries(state.actors.map((id) => [id, id])) },
|
|
slot: { label: 'Prop slot', type: 'select', value: state.slot,
|
|
options: { right: 'Right hand', left: 'Left hand', floor: 'Floor' } },
|
|
stage: { label: 'Scope', type: 'select', value: state.stage,
|
|
options: () => ({ 0: 'Default (all stages)', ...Object.fromEntries(state.stages.map((id) => [String(id), `Stage p${id}`])) }) },
|
|
mode: { label: 'Behavior', type: 'select', value: initial.mode,
|
|
options: () => ({ item: 'Use item', ...state.stage !== '0' ? { hidden: 'Hide prop' } : {},
|
|
inherit: state.stage === '0' ? 'Remove default prop' : 'Inherit default prop' }) },
|
|
_assets: { type: 'info', text: '### Minecraft assets\nUse local Minecraft assets or a manual PNG for the editor preview. The preview is not exported.' ,
|
|
condition: (result) => result.mode === 'item' },
|
|
source: { label: 'Version / asset source', type: 'select', value: state.source,
|
|
options: () => ({ '': 'Select assets...', ...Object.fromEntries(state.sources.map((entry) => [entry.file, entry.label])) }),
|
|
condition: (result) => result.mode === 'item' },
|
|
jar: { label: 'Add client JAR', type: 'file', extensions: ['jar', 'zip'], readtype: 'none', value: '',
|
|
condition: (result) => result.mode === 'item' },
|
|
folder: { label: 'Add extracted assets folder', type: 'folder', value: '',
|
|
condition: (result) => result.mode === 'item' },
|
|
item: { label: 'Item ID', type: 'text', value: initial.item, placeholder: 'minecraft:carrot',
|
|
description: 'The in-game item saved to the animdef. You can also type an ID not listed below.',
|
|
condition: (result) => result.mode === 'item' },
|
|
item_choice: { label: 'Choose item', type: 'select', value: '',
|
|
options: () => ({ '': 'Select an item...', ...Object.fromEntries(state.items.map((id) => [id, id])) }),
|
|
condition: (result) => result.mode === 'item' && !!state.items.length },
|
|
_manual: { type: 'info', text: '### Manual texture fallback\nFor items with a special renderer, choose a PNG to preview an extruded sprite. This does not change the in-game model.',
|
|
condition: (result) => result.mode === 'item' },
|
|
texture: { label: 'Preview texture (optional)', type: 'file', extensions: ['png'], readtype: 'none', value: initial.texture,
|
|
condition: (result) => result.mode === 'item' },
|
|
preset: { label: 'Display preset', type: 'select', value: initial.preset,
|
|
options: { generated: 'Generated item (food, materials)', handheld: 'Handheld item (tools, swords)' },
|
|
condition: (result) => result.mode === 'item' && !!result.texture },
|
|
};
|
|
const dialog = new Dialog({ id: 'animationdirector_props', title: 'Item Props', width: 760, form,
|
|
buttons: ['Cancel', 'Apply prop'], cancelIndex: 0, confirmIndex: 1,
|
|
onFormChange(result) {
|
|
const nextSource = result.jar && result.jar !== state.jar ? result.jar
|
|
: result.folder && result.folder !== state.folder ? result.folder : result.source;
|
|
const sourceChanged = nextSource !== state.source;
|
|
const targetChanged = result.animation !== state.animation || result.actor !== state.actor
|
|
|| result.slot !== state.slot || result.stage !== state.stage;
|
|
state.jar = result.jar; state.folder = result.folder;
|
|
if (sourceChanged) {
|
|
state.source = nextSource; addSource(nextSource);
|
|
state.choice = '';
|
|
this.form.setValues({ source: nextSource, item_choice: '' }, false);
|
|
try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
|
|
}
|
|
if (result.animation !== state.animation) {
|
|
state.animation = result.animation; state.actor = ''; state.stage = '0'; selectAnimation();
|
|
const previousSource = state.source, entry = savedEntry();
|
|
state.choice = '';
|
|
this.form.setValues({ actor: state.actor, stage: state.stage, source: state.source, item_choice: '', ...entry }, false);
|
|
if (state.source !== previousSource) {
|
|
try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
|
|
state.choice = '';
|
|
this.form.setValues({ item_choice: '' }, false);
|
|
}
|
|
} else if (result.actor !== state.actor || result.slot !== state.slot || result.stage !== state.stage) {
|
|
state.actor = result.actor; state.slot = result.slot; state.stage = result.stage;
|
|
const previousSource = state.source, entry = savedEntry();
|
|
state.choice = '';
|
|
this.form.setValues({ source: state.source, item_choice: '', ...entry }, false);
|
|
if (state.source !== previousSource) {
|
|
try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
|
|
state.choice = '';
|
|
this.form.setValues({ item_choice: '' }, false);
|
|
}
|
|
}
|
|
if (!sourceChanged && !targetChanged && result.item_choice && result.item_choice !== state.choice) {
|
|
state.choice = result.item_choice;
|
|
this.form.setValues({ item: result.item_choice }, false);
|
|
}
|
|
this.form.update(this.form.getResult());
|
|
},
|
|
onConfirm(result) {
|
|
if (state.busy) return false;
|
|
state.busy = true;
|
|
const values = { animation: result.animation, actor: result.actor, slot: result.slot, stage: result.stage,
|
|
mode: result.mode, item: result.item, source: state.source, texture: result.texture, preset: result.preset };
|
|
void applyPropPreview(values).then((warnings) => {
|
|
localStorage.setItem('animationdirector_prop_source', state.source);
|
|
const index = getSortedUsedActorIndicesFromProject().find((index) => getActorExportLabel(index) === values.actor);
|
|
const missingBone = !Group.all.some((group) => group.name === `actor${index}_${PROP_SLOTS[values.slot]}`);
|
|
this.hide();
|
|
Blockbench.showQuickMessage([`Item prop saved.`, ...warnings,
|
|
...(missingBone ? [`Add the ${PROP_SLOTS[values.slot]} bone to see its preview.`] : [])].join(' '), 4500);
|
|
}).catch((error) => {
|
|
Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] });
|
|
}).finally(() => { state.busy = false; });
|
|
return false;
|
|
},
|
|
});
|
|
propDialog = dialog;
|
|
}
|
|
|
|
function showPropDialog() {
|
|
if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500);
|
|
try {
|
|
if (!collectConjoinedAnimationGroups().groups.length) {
|
|
Blockbench.showQuickMessage('Create or import an animation before editing item props.', 3000);
|
|
return;
|
|
}
|
|
propDialog?.delete();
|
|
createPropDialog();
|
|
propDialog.show();
|
|
} catch (error) {
|
|
Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] });
|
|
}
|
|
}
|
|
|
|
function desktopFiles() {
|
|
const load = typeof requireNativeModule === 'function' ? requireNativeModule
|
|
: typeof require === 'function' ? require : null;
|
|
if (!load) throw new Error('Automatic import requires desktop Blockbench. The Manual Import tools remain available in the browser.');
|
|
const fs = load('fs'), path = load('path');
|
|
if (!fs || !['readFileSync', 'statSync', 'existsSync', 'readdirSync'].every((method) => typeof fs[method] === 'function')) {
|
|
throw new Error('Filesystem access is unavailable. Allow filesystem access for AnimationDirector and try again.');
|
|
}
|
|
return { fs, path };
|
|
}
|
|
|
|
function importFilePath(file, io) {
|
|
if (typeof io.fs.realpathSync === 'function') return io.fs.realpathSync(file);
|
|
const resolved = io.path.resolve(file);
|
|
// Blockbench's scoped filesystem omits realpath/lstat. Dirent can still detect links.
|
|
// Reject linked ancestors rather than silently weakening resource containment checks.
|
|
io.checkedImportPaths ||= new Set();
|
|
const check = (candidate) => {
|
|
if (io.checkedImportPaths.has(candidate)) return;
|
|
const parent = io.path.dirname(candidate);
|
|
if (parent !== candidate) {
|
|
check(parent);
|
|
io.importDirectoryEntries ||= new Map();
|
|
if (!io.importDirectoryEntries.has(parent)) {
|
|
io.importDirectoryEntries.set(parent, io.fs.readdirSync(parent, { withFileTypes: true }));
|
|
}
|
|
const name = io.path.basename(candidate);
|
|
const entry = io.importDirectoryEntries.get(parent).find((item) => io.path.sep === '\\'
|
|
? item.name.toLowerCase() === name.toLowerCase() : item.name === name);
|
|
if (entry?.isSymbolicLink()) throw new Error(`Linked paths are not supported by this Blockbench filesystem. Select the actual extracted pack folder: ${candidate}`);
|
|
}
|
|
io.checkedImportPaths.add(candidate);
|
|
};
|
|
check(resolved);
|
|
return resolved;
|
|
}
|
|
|
|
function savedPackFolders() {
|
|
try {
|
|
const value = JSON.parse(localStorage.getItem(PACK_FOLDERS_KEY) || '[]');
|
|
return Array.isArray(value) ? uniqueTrimmedStrings(value.filter((item) => typeof item === 'string')) : [];
|
|
} catch (_) { return []; }
|
|
}
|
|
|
|
function readImportJson(file, io) {
|
|
if (io.fs.statSync(file).size > 128 * 1024 * 1024) throw new Error(`JSON file is too large: ${file}`);
|
|
return JSON.parse(io.fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
}
|
|
|
|
function insidePack(root, relative, io) {
|
|
const file = io.path.resolve(root, relative);
|
|
const contained = (candidate) => {
|
|
const rel = io.path.relative(root, candidate);
|
|
return rel !== '..' && !rel.startsWith(`..${io.path.sep}`) && !io.path.isAbsolute(rel);
|
|
};
|
|
if (!contained(file)) throw new Error(`Resource path escapes its pack: ${relative}`);
|
|
if (!io.fs.existsSync(file)) return null;
|
|
const real = importFilePath(file, io);
|
|
if (!contained(real)) throw new Error(`Resource symlink escapes its pack: ${relative}`);
|
|
return real;
|
|
}
|
|
|
|
function importResourceId(value) {
|
|
if (typeof value !== 'string' || !RESOURCE_ID_PATTERN.test(value)) return null;
|
|
const [namespace, resource] = value.split(':');
|
|
if (resource.split('/').some((part) => !part || part === '.' || part === '..')) return null;
|
|
return { namespace, resource };
|
|
}
|
|
|
|
function readImportPack(folder, io) {
|
|
const root = importFilePath(folder, io);
|
|
const metadata = readImportJson(insidePack(root, 'pack.mcmeta', io) || (() => {
|
|
throw new Error(`Select an extracted pack folder containing pack.mcmeta: ${folder}`);
|
|
})(), io);
|
|
const afw = metadata.animationframework || {};
|
|
return { root, metadata: afw, id: importResourceId(afw.id) ? afw.id : null,
|
|
name: typeof afw.name === 'string' ? afw.name : io.path.basename(root) };
|
|
}
|
|
|
|
function findImportPack(file, io) {
|
|
let folder = io.path.dirname(io.path.resolve(file));
|
|
while (true) {
|
|
if (io.fs.existsSync(io.path.join(folder, 'pack.mcmeta'))) return readImportPack(folder, io);
|
|
const parent = io.path.dirname(folder);
|
|
if (parent === folder) throw new Error('The selected file must be inside an extracted pack containing pack.mcmeta. ZIP files are not supported.');
|
|
folder = parent;
|
|
}
|
|
}
|
|
|
|
function importPackRegistry(source, folders, io) {
|
|
const packs = [], warnings = [], seen = new Set();
|
|
// An unregistered source goes first; registered sources retain their configured priority.
|
|
for (const folder of [ ...folders, source.root ]) {
|
|
try {
|
|
const pack = readImportPack(folder, io);
|
|
if (!seen.has(pack.root)) { packs.push(pack); seen.add(pack.root); }
|
|
} catch (error) { warnings.push(error.message); }
|
|
}
|
|
if (!folders.some((folder) => {
|
|
try { return importFilePath(folder, io) === source.root; } catch (_) { return false; }
|
|
})) {
|
|
const index = packs.findIndex((pack) => pack.root === source.root);
|
|
if (index >= 0) packs.unshift(...packs.splice(index, 1));
|
|
}
|
|
const ids = new Set();
|
|
for (const pack of packs) {
|
|
if (pack.id && ids.has(pack.id)) warnings.push(`Duplicate pack ID ${pack.id}; configured folder priority decides resource overrides.`);
|
|
if (pack.id) ids.add(pack.id);
|
|
}
|
|
return { packs, warnings };
|
|
}
|
|
|
|
function findImportResource(packs, id, directory, extension, io) {
|
|
const parsed = importResourceId(id);
|
|
if (!parsed) return null;
|
|
const relative = `assets/${parsed.namespace}/${directory}${parsed.resource}${extension}`;
|
|
for (const pack of packs) {
|
|
const file = insidePack(pack.root, relative, io);
|
|
if (file && io.fs.statSync(file).isFile()) return { file, pack, id };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function importModelCandidates(entity, gender, variant, slim = false) {
|
|
const parsed = importResourceId(entity);
|
|
if (!parsed || !['m', 'f', 'mf'].includes(gender)) throw new Error('Choose a concrete entity ID and model gender for every actor.');
|
|
if (variant && (!/^[a-z0-9/._-]+$/.test(variant) || variant.split('/').some((part) => !part || part === '..' || part === '.'))) {
|
|
throw new Error(`Invalid entity variant: ${variant}`);
|
|
}
|
|
const paths = variant ? [`${parsed.resource}_${variant}`, parsed.resource] : [parsed.resource];
|
|
if (slim && entity === 'minecraft:player') paths.unshift('player_slim');
|
|
const fallbacks = gender === 'mf' ? ['m', 'f'] : [gender === 'm' ? 'f' : 'm'];
|
|
return [...paths.map((base) => `${base}.${gender}`), ...paths,
|
|
...fallbacks.flatMap((suffix) => paths.map((base) => `${base}.${suffix}`))];
|
|
}
|
|
|
|
function resolveImportModel(source, packs, entity, gender, variant, io, slim = false) {
|
|
const roots = [];
|
|
const addRoot = (id, kind) => {
|
|
if (roots.length < 32 && importResourceId(id) && !roots.some((root) => root.id === id)) roots.push({ id, kind });
|
|
};
|
|
if (source.id) addRoot(`${source.id}/entity`, 'Local');
|
|
for (const entry of Array.isArray(source.metadata.model_imports) ? source.metadata.model_imports : []) {
|
|
if (entry?.pack !== source.id && Array.isArray(entry?.entities) && entry.entities.includes(entity)) {
|
|
addRoot(`${entry.pack}/entity`, `Imported ${entry.pack}`);
|
|
}
|
|
}
|
|
const revision = (value) => Number.isInteger(value) && value > 0 ? value : 1;
|
|
const target = revision(source.metadata.default_model_revision);
|
|
const current = Math.max(1, ...packs.filter((pack) => pack.id === 'needsofnature:default_pack')
|
|
.map((pack) => revision(pack.metadata.default_model_revision)));
|
|
// Sparse archives: only changed models exist in each old revision directory.
|
|
for (let number = target; number < current && roots.length < 32; number++) {
|
|
addRoot(`needsofnature:default_pack_compat/revision_${number}/entity`, `Default revision ${number}`);
|
|
}
|
|
roots.push({ id: 'animationframework:entity', kind: 'Shared' });
|
|
roots.push({ id: 'animationframework:', kind: 'Shared legacy' });
|
|
// Gender-specific candidates precede generic candidates across ALL providers, as in NoN.
|
|
for (const candidate of importModelCandidates(entity, gender, variant, slim)) {
|
|
for (const root of roots) {
|
|
const id = root.id.endsWith(':') ? root.id + candidate : `${root.id}/${candidate}`;
|
|
const found = findImportResource(packs, id, 'geckolib/models/', '.geo.json', io);
|
|
if (found) return { ...found, kind: root.kind };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function definitionActorKeys(actors) {
|
|
const sanitize = (value) => String(value).toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
const counts = new Map();
|
|
return actors.map((actor) => {
|
|
let base;
|
|
if (actor.label) base = sanitize(actor.label);
|
|
else {
|
|
base = (actor.entity_types || []).map((id) => String(id).split(':').pop()).sort().join('_') || 'actor';
|
|
if (actor.actor_tags?.length) base += '_' + [...actor.actor_tags].sort().map(sanitize).join('_');
|
|
if (actor.actor_tags_any?.length) base += '_any_' + [...actor.actor_tags_any].sort().map(sanitize).join('_');
|
|
if (actor.entity_variant) base += '_' + actor.entity_variant;
|
|
if (actor.age === 'baby') base += '_baby';
|
|
base = sanitize(base);
|
|
}
|
|
const count = (counts.get(base) || 0) + 1;
|
|
counts.set(base, count);
|
|
return count === 1 ? base : `${base}_${count}`;
|
|
});
|
|
}
|
|
|
|
function inferImportGender(actor) {
|
|
const required = actor.actor_tags || [];
|
|
const male = required.includes('gender.male'), female = required.includes('gender.female');
|
|
// Matching either flag also permits male+female actors: let creators select the intended model.
|
|
if (male && female) return 'mf';
|
|
return '';
|
|
}
|
|
|
|
function defaultImportClipRoles(actors, clipLabels) {
|
|
const keys = definitionActorKeys(actors);
|
|
const assigned = actors.map((_, index) => clipLabels.includes(keys[index]) ? keys[index] : '');
|
|
const entityNames = actors.map((actor) => actor.entity_types?.length === 1
|
|
? String(actor.entity_types[0]).split(':').pop() : '');
|
|
actors.forEach((_, index) => {
|
|
const name = entityNames[index];
|
|
// Older clips often omit constraint tags. Only infer a short label when unambiguous.
|
|
if (!assigned[index] && name && clipLabels.includes(name) && !assigned.includes(name)
|
|
&& entityNames.filter((candidate, position) => candidate === name && !assigned[position]).length === 1) assigned[index] = name;
|
|
});
|
|
return assigned;
|
|
}
|
|
|
|
function validateImportGeometry(json) {
|
|
const geo = extractGeckoGeometryRoot(json);
|
|
if (!geo || !Array.isArray(geo.bones) || !geo.bones.length) throw new Error('The model must contain a non-empty minecraft:geometry bones array.');
|
|
const bones = new Map();
|
|
for (const bone of geo.bones) {
|
|
if (!bone || typeof bone.name !== 'string' || !bone.name || bones.has(bone.name)) throw new Error('Model bone names must be non-empty and unique.');
|
|
bones.set(bone.name, bone);
|
|
}
|
|
const completed = new Set();
|
|
for (const bone of bones.values()) {
|
|
const visited = new Set();
|
|
let cursor = bone;
|
|
while (cursor && !completed.has(cursor.name)) {
|
|
if (visited.has(cursor.name)) throw new Error(`Cyclic model hierarchy at ${cursor.name}.`);
|
|
visited.add(cursor.name);
|
|
cursor = bones.get(cursor.parent);
|
|
}
|
|
for (const name of visited) completed.add(name);
|
|
}
|
|
return geo;
|
|
}
|
|
|
|
function companionImportFiles(source, file, io) {
|
|
const relative = io.path.relative(source.root, io.path.resolve(file)).replace(/\\/g, '/');
|
|
const definition = /^data\/([^/]+)\/afw_animdefs\/(.+)\.json$/.exec(relative);
|
|
const animation = /^assets\/([^/]+)\/(?:geckolib\/)?animations\/afw\/(.+)\.animation\.json$/.exec(relative);
|
|
const match = definition || animation;
|
|
if (!match || !importResourceId(`${match[1]}:${match[2]}`)) {
|
|
throw new Error('Choose data/<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', onFormChange = null) {
|
|
return new Promise((resolve) => new Dialog({ id: 'animationdirector_automatic_import_step', title, width: 680, form,
|
|
buttons: ['Cancel', confirmText], cancelIndex: 0, confirmIndex: 1,
|
|
...(onFormChange ? { onFormChange } : {}),
|
|
onConfirm(result) { resolve(result); }, onCancel() { resolve(null); } }).show());
|
|
}
|
|
|
|
function pickImportFile(title) {
|
|
return new Promise((resolve) => Filesystem.importFile({ type: 'JSON', title, extensions: ['json'],
|
|
multiple: false, readtype: 'text' }, (files) => resolve(files?.[0] || null)));
|
|
}
|
|
|
|
async function chooseCompanion(files, title) {
|
|
if (files.length === 1) return files[0];
|
|
if (!files.length) return (await pickImportFile(`${title} (not found in pack)`))?.path || null;
|
|
const result = await importDialog(title, { file: { type: 'select', label: 'File', value: files[0],
|
|
options: { '': 'Choose a file...', ...Object.fromEntries(files.map((file) => [file, file])) } } });
|
|
if (!result) return null;
|
|
if (!files.includes(result.file)) throw new Error('Select the companion file explicitly.');
|
|
return result.file;
|
|
}
|
|
|
|
function editImportPackFolders() {
|
|
const io = desktopFiles();
|
|
const folders = savedPackFolders();
|
|
const dialog = new Dialog({ id: 'animationdirector_pack_folders', title: 'Animation Import Pack Folders', width: 680,
|
|
component: {
|
|
data() { return { folders, error: '' }; },
|
|
methods: {
|
|
add() {
|
|
const folder = Blockbench.pickDirectory({ title: 'Select extracted pack folder', resource_id: PACK_FOLDERS_KEY });
|
|
if (!folder) return;
|
|
try {
|
|
const pack = readImportPack(folder, io);
|
|
if (!this.folders.includes(pack.root)) this.folders.push(pack.root);
|
|
this.error = '';
|
|
} catch (error) { this.error = error.message; }
|
|
},
|
|
move(index, direction) {
|
|
const next = index + direction;
|
|
if (next < 0 || next >= this.folders.length) return;
|
|
const item = this.folders.splice(index, 1)[0];
|
|
this.folders.splice(next, 0, item);
|
|
},
|
|
},
|
|
template: `<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 createImportAssetLookup(io, paths) {
|
|
const opened = new Map(), resources = new Map();
|
|
return {
|
|
*sources() {
|
|
paths ||= discoverPropSources(io).map((entry) => entry.file);
|
|
for (const file of paths) {
|
|
let source = opened.get(file);
|
|
if (source === undefined) {
|
|
try { source = openPropSource(file, io); } catch (_) { source = null; }
|
|
opened.set(file, source);
|
|
if (opened.size > 2) opened.delete(opened.keys().next().value);
|
|
}
|
|
if (source) yield source;
|
|
}
|
|
},
|
|
read(relative) {
|
|
if (resources.has(relative)) return resources.get(relative);
|
|
for (const source of this.sources()) {
|
|
try {
|
|
const bytes = source.read(relative);
|
|
if (!bytes || bytes.length > 4 * 1024 * 1024 || !pngDimensions(bytes)) continue;
|
|
let metadata;
|
|
try { metadata = propJson(source, `${relative}.mcmeta`); } catch (_) { /* Static texture. */ }
|
|
const result = { bytes, relative, dimensions: importTextureDimensions(bytes, metadata) };
|
|
resources.set(relative, result);
|
|
return result;
|
|
} catch (_) { /* Try the next source. */ }
|
|
}
|
|
resources.set(relative, null);
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
function loadImportTextures(row, packs, io, assets = createImportAssetLookup(io)) {
|
|
const candidates = actorTextureCandidates(row.json, row.geo, row.resolved.file, row.entity, row.slim);
|
|
row.textures = new Map();
|
|
row.defaultTexture = undefined;
|
|
let modelPack;
|
|
try { modelPack = findImportPack(row.resolved.file, io); } catch (_) { /* Standalone model. */ }
|
|
const searchPacks = modelPack && !packs.some((pack) => pack.root === modelPack.root)
|
|
? [modelPack, ...packs] : packs;
|
|
const resolveTexture = (resource) => {
|
|
const id = resource.includes(':') ? resource : `minecraft:${resource}`;
|
|
const texture = findImportResource(searchPacks, id, '', '', io);
|
|
if (texture) {
|
|
let dimensions = null;
|
|
try {
|
|
if (io.fs.statSync(texture.file).size <= 4 * 1024 * 1024) {
|
|
const bytes = io.fs.readFileSync(texture.file);
|
|
let metadata = null;
|
|
try { metadata = readImportJson(`${texture.file}.mcmeta`, io); } catch (_) { /* Static texture. */ }
|
|
dimensions = importTextureDimensions(bytes, metadata);
|
|
}
|
|
} catch (_) { /* Let Blockbench report unreadable texture files. */ }
|
|
return { ...texture, dimensions };
|
|
}
|
|
const parsed = importResourceId(id);
|
|
if (!parsed) return null;
|
|
const relative = `assets/${parsed.namespace}/${parsed.resource}`;
|
|
return assets.read(relative);
|
|
};
|
|
for (const resource of candidates) {
|
|
const texture = resolveTexture(resource);
|
|
if (!texture) continue;
|
|
row.textures.set(resource, texture);
|
|
row.defaultTexture = resource;
|
|
break;
|
|
}
|
|
for (const resource of Object.values(row.json.afw_bone_textures || {})) {
|
|
if (typeof resource !== 'string' || row.textures.has(resource)) continue;
|
|
const texture = resolveTexture(resource);
|
|
if (texture) row.textures.set(resource, texture);
|
|
}
|
|
}
|
|
|
|
function commitAutomaticImport(plan) {
|
|
if (!Formats.geckolib_model) throw new Error('Install/enable the GeckoLib Blockbench plugin before importing an animation.');
|
|
cancelBlockRequirementUpdate();
|
|
if (!newProject(Formats.geckolib_model)) throw new Error('Could not create the GeckoLib project.');
|
|
const projectId = Project.uuid;
|
|
const aspects = { outliner: true, selection: true, elements: [], groups: [], textures: [], animations: [], uv_mode: true,
|
|
automatic_animation_import: true };
|
|
Undo.initEdit(aspects);
|
|
try {
|
|
Project.name = plan.parsed.animationId;
|
|
const description = plan.rows[0]?.geo.description || {};
|
|
const dimension = (value) => Number.isFinite(value) && value > 0 ? value : 64;
|
|
Project.texture_width = dimension(description.texture_width);
|
|
Project.texture_height = dimension(description.texture_height);
|
|
const names = {}, textures = {}, metadata = {}, overrides = {}, loadedTextures = new Map();
|
|
for (const [offset, row] of plan.rows.entries()) {
|
|
const index = offset + 1;
|
|
names[index] = row.label;
|
|
metadata[index] = plan.definition.actors[offset];
|
|
importGeometryIntoCurrentProject(row.geo, index, false, false);
|
|
for (const [name, resource] of row.textures) {
|
|
const key = resource.file || `${resource.relative}:${name}`;
|
|
if (!loadedTextures.has(key)) {
|
|
const texture = new Texture({ name, uv_width: resource.dimensions?.width || row.geo.description?.texture_width || 64,
|
|
uv_height: resource.dimensions?.height || row.geo.description?.texture_height || 64 });
|
|
texture.keep_size = true;
|
|
texture.load_callback = () => { if (Project?.uuid === projectId) scheduleActorTexturePreviewApply(); };
|
|
if (resource.file) texture.fromPath(resource.file);
|
|
else texture.fromDataURL(`data:image/png;base64,${resource.bytes.toString('base64')}`);
|
|
texture.name = name;
|
|
if (resource.relative) texture.relative_path = resource.relative;
|
|
texture.add(false);
|
|
loadedTextures.set(key, name);
|
|
}
|
|
}
|
|
const base = row.textures.get(row.defaultTexture);
|
|
if (base) {
|
|
textures[index] = loadedTextures.get(base.file || `${base.relative}:${row.defaultTexture}`);
|
|
}
|
|
for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) {
|
|
const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`);
|
|
const boneTexture = row.textures.get(textureName);
|
|
if (group && boneTexture) overrides[group.uuid] = { uuid: group.uuid, name: group.name,
|
|
texture: loadedTextures.get(boneTexture.file || `${boneTexture.relative}:${textureName}`) };
|
|
}
|
|
}
|
|
setProjectActorNames(names);
|
|
setProjectActorTextures(textures);
|
|
setProjectActorAnimdefMetadata(metadata);
|
|
setProjectBoneTextureOverrides(overrides);
|
|
setProjectAnimdefAnimators(plan.definition.animators || []);
|
|
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
|
|
mapAnimationSounds(Animation.all);
|
|
mapAnimationParticles(Animation.all);
|
|
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
|
|
if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries);
|
|
Canvas.updateAll();
|
|
applyAllActorTexturePreviews(false);
|
|
refreshBlockGuideFaces();
|
|
const finished = { ...aspects, elements: Outliner.elements.slice(), groups: Group.all.slice(),
|
|
textures: Texture.all.slice(), animations: Animation.all.slice() };
|
|
Undo.finishEdit('Import Whole Animation', finished);
|
|
Project.saved = false;
|
|
if (Animation.all[0]) Animation.all[0].select();
|
|
if (plan.propEntries) void refreshPropPreviews(true);
|
|
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(plan.parsed.animationId);
|
|
Blockbench.showQuickMessage(`Imported ${plan.parsed.animationId}: ${plan.rows.length} actors, ${plan.imported.stageNames.length} stages.`, 4000);
|
|
} catch (error) {
|
|
Undo.cancelEdit();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function automaticallyImportAnimation() {
|
|
try {
|
|
const io = desktopFiles();
|
|
const file = await pickImportFile('Import Whole 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. Use Manual Import > Pack Folders to register shared/default packs before importing.\nFolder priority: ${registry.packs.map((pack) => pack.name).join(' > ')}` },
|
|
actors_section: { type: 'info', text: '### Actors\nChoose concrete actors and confirm their animation roles.' },
|
|
};
|
|
definition.actors.forEach((actor, index) => {
|
|
const entities = (actor.entity_types || []).filter((id) => importResourceId(id));
|
|
form[`heading_${index}`] = { type: 'info', text: `### Actor ${index + 1}: ${keys[index]}` };
|
|
form[`clip_${index}`] = { type: 'select', label: 'Animation actor', value: clipRoles[index],
|
|
options: { '': 'Choose actor...', ...Object.fromEntries(parsed.actorLabels.map((label) => [label, label])) } };
|
|
form[`entity_${index}`] = entities.length ? { type: 'select', label: 'Entity', value: entities[0],
|
|
options: Object.fromEntries(entities.map((id) => [id, id])) }
|
|
: { type: 'text', label: 'Concrete entity ID', value: '', placeholder: 'minecraft:player' };
|
|
const required = actor.actor_tags || [];
|
|
const genders = {};
|
|
if (!required.includes('gender.female')) genders.m = 'Male';
|
|
if (!required.includes('gender.male')) genders.f = 'Female';
|
|
genders.mf = 'Male + female';
|
|
form[`gender_${index}`] = { type: 'select', label: 'Model gender', value: inferImportGender(actor) || Object.keys(genders)[0], options: genders };
|
|
if (!entities.length || entities.includes('minecraft:player')) form[`slim_${index}`] = { type: 'checkbox', label: 'Slim player model', value: false,
|
|
condition: (result) => result[`entity_${index}`] === 'minecraft:player' };
|
|
});
|
|
form.models_section = { type: 'info', text: '### Actor Models\nModels are selected automatically from the actor choices above, following local, imported, old default revision, and shared priority. Select a file only to override that lookup or supply a missing model.' };
|
|
const modelStatus = (index, values) => {
|
|
const entity = values[`entity_${index}`], gender = values[`gender_${index}`];
|
|
if (!importResourceId(entity) || !['m', 'f', 'mf'].includes(gender)) return 'Choose an entity and model gender above.';
|
|
try {
|
|
const resolved = resolveImportModel(source, registry.packs, entity, gender,
|
|
definition.actors[index].entity_variant, io, !!values[`slim_${index}`]);
|
|
return resolved ? `${resolved.kind}: ${resolved.file}` : 'No automatic model found. Select a model file below.';
|
|
} catch (error) { return `Model lookup unavailable: ${error.message}`; }
|
|
};
|
|
const defaults = Object.fromEntries(Object.entries(form).filter(([, field]) => 'value' in field)
|
|
.map(([key, field]) => [key, field.value]));
|
|
definition.actors.forEach((_, index) => {
|
|
form[`source_${index}`] = { type: 'text', label: `Actor ${index + 1} automatic model`,
|
|
value: modelStatus(index, defaults), readonly: true, full_width: true };
|
|
form[`model_${index}`] = { type: 'file', label: `Actor ${index + 1} model override`,
|
|
return_as: 'file', extensions: ['json'], readtype: 'text', filetype: 'GeckoLib geometry' };
|
|
});
|
|
const choices = await importDialog('Import Whole Animation: Actors & Models', form, 'Create Project and Import', function(result) {
|
|
this.form.setValues(Object.fromEntries(definition.actors.map((_, index) =>
|
|
[`source_${index}`, modelStatus(index, result)])), false);
|
|
});
|
|
if (!choices) return;
|
|
const rows = definition.actors.map((actor, index) => ({ actor, label: choices[`clip_${index}`], entity: choices[`entity_${index}`], gender: choices[`gender_${index}`], slim: !!choices[`slim_${index}`] }));
|
|
if (new Set(rows.map((row) => row.label)).size !== rows.length || rows.some((row) => !parsed.actorLabels.includes(row.label))) {
|
|
throw new Error('Each definition actor must be assigned a distinct animation actor.');
|
|
}
|
|
const selectedLabels = new Set(rows.map((row) => row.label));
|
|
const ignoredLabels = parsed.actorLabels.filter((label) => !selectedLabels.has(label));
|
|
// The definition owns the actor set; discard all tracks and effects belonging to unused clips.
|
|
parsed.actorLabels = parsed.actorLabels.filter((label) => selectedLabels.has(label));
|
|
parsed.stages = parsed.stages.map((stage) => ({ ...stage,
|
|
entries: stage.entries.filter((entry) => selectedLabels.has(entry.actorLabel))
|
|
})).filter((stage) => stage.entries.length);
|
|
parsed.stages = resolveDefinitionImportStages(parsed, definition);
|
|
// Preserve role references while relabeling the definition for the selected asset clips.
|
|
const relabeled = clone(definition);
|
|
relabeled.actors.forEach((actor, index) => { actor.label ||= keys[index]; });
|
|
const analysis = { actorIndices: rows.map((_, index) => index + 1), actorLabels: new Map(rows.map((row, index) => [index + 1, row.label])),
|
|
stages: parsed.stages.map((stage) => ({ stageNumber: stage.stageNumber })) };
|
|
const labelMap = new Map(relabeled.actors.map((actor, index) => [actor.label, rows[index].label]));
|
|
relabeled.actors.forEach((actor, index) => {
|
|
actor.label = rows[index].label;
|
|
if (actor.injects_into !== undefined) {
|
|
const targets = Array.isArray(actor.injects_into) ? actor.injects_into : [actor.injects_into];
|
|
actor.injects_into = targets.map((target) => {
|
|
if (!labelMap.has(target)) throw new Error(`Unknown injection target: ${target}`);
|
|
return labelMap.get(target);
|
|
});
|
|
}
|
|
});
|
|
for (const stage of relabeled.stages || []) {
|
|
if (stage.props && typeof stage.props === 'object' && !Array.isArray(stage.props)) {
|
|
stage.props = Object.fromEntries(Object.entries(stage.props).map(([key, value]) => [labelMap.get(key) || key, value]));
|
|
}
|
|
}
|
|
if (relabeled.position_anchor_actor !== undefined) relabeled.position_anchor_actor = labelMap.get(relabeled.position_anchor_actor) || relabeled.position_anchor_actor;
|
|
const prepared = prepareAnimationDefinitionImport(relabeled, analysis);
|
|
const warnings = registry.warnings.slice();
|
|
if (ignoredLabels.length) warnings.push(`Ignored animation actors not mapped to the definition: ${ignoredLabels.join(', ')}.`);
|
|
const assets = createImportAssetLookup(io);
|
|
for (const [index, row] of rows.entries()) {
|
|
const override = choices[`model_${index}`];
|
|
row.resolved = override?.path ? { file: override.path, kind: 'Manually selected' }
|
|
: resolveImportModel(source, registry.packs, row.entity, row.gender, row.actor.entity_variant, io, row.slim);
|
|
if (!row.resolved) throw new Error(`No model found for ${row.label}. Select a model override for actor ${index + 1} and try again.`);
|
|
row.json = readImportJson(row.resolved.file, io);
|
|
row.geo = validateImportGeometry(row.json);
|
|
loadImportTextures(row, registry.packs, io, assets);
|
|
const names = new Set(row.geo.bones.map((bone) => bone.name));
|
|
const missing = new Set(parsed.stages.flatMap((stage) => stage.entries.filter((entry) => entry.actorLabel === row.label)
|
|
.flatMap((entry) => Object.keys(entry.animObj.bones || {}).filter((bone) => !names.has(bone)))));
|
|
if (missing.size) warnings.push(`${row.label}: model lacks animated bones: ${[...missing].join(', ')}. Tracks will be retained.`);
|
|
if (!row.textures.has(row.defaultTexture)) warnings.push(`${row.label}: no base texture found locally. Set its preview texture using Actor Preferences.`);
|
|
}
|
|
const imported = buildConjoinedImportFile(parsed, parsed.animationId, new Map(rows.map((row, index) => [row.label, index + 1])));
|
|
let blockEntry = null;
|
|
if (prepared.block_requirements) {
|
|
const fields = buildBlockRequirementForm(parsed.animationId, prepared.block_requirements);
|
|
blockEntry = readBlockRequirementForm(Object.fromEntries(Object.entries(fields).filter(([, value]) => value.type !== 'info').map(([key, value]) => [key, value.value])));
|
|
buildBlockRequirementGuideBoxes(blockEntry);
|
|
}
|
|
const props = importPropPreviews(parsed.animationId, prepared, registry.packs.map((pack) => pack.root));
|
|
warnings.push(...props.warnings);
|
|
commitAutomaticImport({ parsed, rows, definition: prepared, imported, blockEntry, propEntries: props.entries });
|
|
localStorage.setItem(PACK_FOLDERS_KEY, JSON.stringify(registry.packs.map((pack) => pack.root)));
|
|
if (warnings.length) {
|
|
console.warn(`[AnimationDirector] Import notes for ${parsed.animationId}:\n${warnings.join('\n')}`);
|
|
const more = warnings.length > 1 ? ` (+${warnings.length - 1} more in Blockbench console)` : '';
|
|
Blockbench.showQuickMessage(`Imported with notes: ${warnings[0]}${more}`, 6000);
|
|
}
|
|
} catch (error) {
|
|
Blockbench.showMessageBox({ title: 'Import Whole Animation', message: error.message, buttons: ['OK'] });
|
|
}
|
|
}
|
|
|
|
// Sound IDs remain gameplay cues. Blob URLs are editor-only, lazily loaded audio previews.
|
|
function stopSoundAudition() {
|
|
if (soundAudition) { soundAudition.pause(); soundAudition = null; }
|
|
}
|
|
|
|
function clearSoundAudio() {
|
|
stopSoundAudition();
|
|
for (const url of soundAudioUrls.values()) URL.revokeObjectURL(url);
|
|
soundAudioUrls.clear(); soundAudioBytes = 0;
|
|
}
|
|
|
|
function soundJsonPaths(source, io) {
|
|
if (source.files) return [...source.files.keys()].filter((name) => /^assets\/[^/]+\/sounds\.json$/.test(name));
|
|
const root = io.path.basename(source.path) === 'assets' ? io.path.dirname(source.path) : source.path;
|
|
const assets = io.path.join(root, 'assets');
|
|
if (!io.fs.existsSync(assets)) return [];
|
|
return io.fs.readdirSync(assets, { withFileTypes: true }).slice(0, 256)
|
|
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && /^[a-z0-9_.-]+$/.test(entry.name))
|
|
.map((entry) => `assets/${entry.name}/sounds.json`);
|
|
}
|
|
|
|
function loadSoundLibrary(io = desktopFiles()) {
|
|
clearSoundAudio();
|
|
const events = new Map(), sources = [], warnings = [];
|
|
const selected = localStorage.getItem(SOUND_SOURCE_KEY);
|
|
const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : [])]);
|
|
for (const file of paths.slice(0, 64)) {
|
|
try {
|
|
const source = openPropSource(file, io);
|
|
sources.push(source);
|
|
for (const name of soundJsonPaths(source, io)) {
|
|
const raw = source.read(name);
|
|
if (!raw) continue;
|
|
const json = JSON.parse(raw.toString('utf8').replace(/^\uFEFF/, ''));
|
|
const namespace = name.split('/')[1];
|
|
for (const [key, value] of Object.entries(json)) {
|
|
const id = `${namespace}:${key}`;
|
|
if (!importResourceId(id) || !Array.isArray(value?.sounds) || events.size >= 10000) continue;
|
|
const entries = value.sounds.slice(0, 256);
|
|
if (!events.has(id)) events.set(id, { entries, replace: value.replace === true });
|
|
else if (!events.get(id).replace) {
|
|
events.get(id).entries.push(...entries);
|
|
events.get(id).replace = value.replace === true;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); }
|
|
}
|
|
soundLibrary = { events, sources, warnings };
|
|
soundLibraryGeneration++;
|
|
return soundLibrary;
|
|
}
|
|
|
|
function ensureSoundLibrary() {
|
|
if (!soundLibrary) {
|
|
try { loadSoundLibrary(); }
|
|
catch (_) { soundLibrary = { events: new Map(), sources: [], warnings: ['Sound assets require Blockbench Desktop.'] }; }
|
|
}
|
|
return soundLibrary;
|
|
}
|
|
|
|
function isPeakedSoundStage(animation) {
|
|
if (typeof Project === 'undefined' || !Project) return false;
|
|
const parsed = parseAfwStageAnimationName(animation);
|
|
if (!parsed) return false;
|
|
const stages = getSavedAnimdef(parsed.id).stages;
|
|
return Array.isArray(stages) && stages.some((stage) => stage.stage === parsed.stageNumber && stage.non_peak === true);
|
|
}
|
|
|
|
function soundCueId(cue, animation) {
|
|
const text = String(cue || '').trim();
|
|
const bare = text.replace(/^(?:needsofnature|minecraft):/, '');
|
|
if (bare.toLowerCase() === 'reactiveimpact_silent') return null;
|
|
const semantic = { reactiveimpact: isPeakedSoundStage(animation) ? 'shot_in01' : 'impactdry01',
|
|
impactdry: 'impactdry01', impactwet: 'impactwet01', birth: 'pop' };
|
|
if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`;
|
|
const id = text.includes(':') ? text : `needsofnature:${text}`;
|
|
return importResourceId(id) ? id : null;
|
|
}
|
|
|
|
function resolveSoundAudio(cue, library = ensureSoundLibrary(), visiting = new Set(), animation = null) {
|
|
if (/^random\(/i.test(String(cue).trim())) {
|
|
const match = String(cue).trim().match(/^random\((.*)\)$/i);
|
|
if (!match) return null;
|
|
for (const option of match[1].split(',').slice(0, 256)) {
|
|
const audio = resolveSoundAudio(option, library, visiting, animation);
|
|
if (audio) return audio;
|
|
}
|
|
return null;
|
|
}
|
|
const id = soundCueId(cue, animation);
|
|
if (!id || visiting.has(id) || visiting.size >= 16) return null;
|
|
const event = library.events.get(id);
|
|
if (!event) return null;
|
|
const next = new Set(visiting); next.add(id);
|
|
for (const entry of event.entries) {
|
|
const object = typeof entry === 'string' ? { name: entry } : entry;
|
|
if (!object || typeof object.name !== 'string') continue;
|
|
const name = object.name.includes(':') ? object.name : `minecraft:${object.name}`;
|
|
const resource = importResourceId(name);
|
|
if (!resource) continue;
|
|
if (object.type === 'event') {
|
|
const audio = resolveSoundAudio(name, library, next, animation);
|
|
if (audio) return audio;
|
|
continue;
|
|
}
|
|
const asset = `assets/${resource.namespace}/sounds/${resource.resource}.ogg`;
|
|
if (soundAudioUrls.has(asset)) return soundAudioUrls.get(asset);
|
|
for (const source of library.sources) {
|
|
try {
|
|
const bytes = source.read(asset);
|
|
if (!bytes) continue;
|
|
if (soundAudioBytes + bytes.length > 64 * 1024 * 1024) return null;
|
|
const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/ogg' }));
|
|
soundAudioUrls.set(asset, url); soundAudioBytes += bytes.length;
|
|
return url;
|
|
} catch (_) { /* An unavailable asset must never prevent animation import. */ }
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mapAnimationSounds(animations) {
|
|
for (const animation of animations || []) {
|
|
const peaked = isPeakedSoundStage(animation);
|
|
for (const keyframe of animation?.animators?.effects?.sound || []) {
|
|
for (const point of keyframe.data_points || []) {
|
|
if (point.file && point.file !== point[SOUND_PREVIEW_PROPERTY]) continue;
|
|
const signature = `${soundLibraryGeneration}:${peaked}:${point.effect}:${point.sound}:${point.file}`;
|
|
if (soundMappedPoints.get(point) === signature) continue;
|
|
const library = ensureSoundLibrary();
|
|
const audio = resolveSoundAudio(point.effect, library, new Set(), animation)
|
|
|| (point.sound ? resolveSoundAudio(point.sound, library, new Set(), animation) : null);
|
|
point.file = audio || '';
|
|
point[SOUND_PREVIEW_PROPERTY] = point.file;
|
|
soundMappedPoints.set(point, `${soundLibraryGeneration}:${peaked}:${point.effect}:${point.sound}:${point.file}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyLibrarySound(id, insert = false) {
|
|
const animation = typeof Animation === 'undefined' ? null : Animation.selected;
|
|
if (!animation) throw new Error('Select an animation first.');
|
|
const selected = (Timeline.selected || []).filter((keyframe) => keyframe.channel === 'sound' && keyframe.animator?.animation === animation);
|
|
if (!insert && selected.length) {
|
|
Undo.initEdit({ keyframes: selected });
|
|
for (const keyframe of selected) {
|
|
const point = keyframe.data_points[0];
|
|
point.effect = id; point.file = ''; point[SOUND_PREVIEW_PROPERTY] = '';
|
|
}
|
|
mapAnimationSounds([animation]);
|
|
Undo.finishEdit('Apply sound from library');
|
|
} else {
|
|
const effects = animation.animators.effects || (animation.animators.effects = new EffectAnimator(animation));
|
|
const keyframes = effects.sound.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001);
|
|
Undo.initEdit({ keyframes });
|
|
let keyframe = keyframes[0];
|
|
if (keyframe) {
|
|
keyframe.extend({ data_points: [...keyframe.data_points.map((point) => point.getUndoCopy()), { effect: id }] });
|
|
} else {
|
|
keyframe = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'sound', false, false);
|
|
keyframes.push(keyframe);
|
|
}
|
|
mapAnimationSounds([animation]);
|
|
effects.select(); keyframe.select();
|
|
Undo.finishEdit('Insert sound from library', { keyframes });
|
|
}
|
|
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
|
}
|
|
|
|
function particleJsonPaths(source, io) {
|
|
if (source.files) return [...source.files.keys()].filter((name) => /^assets\/[a-z0-9_.-]+\/particles\/[a-z0-9_./-]+\.json$/.test(name)).slice(0, 10000);
|
|
const root = io.path.basename(source.path) === 'assets' ? io.path.dirname(source.path) : source.path;
|
|
const assets = io.path.join(root, 'assets'), paths = [];
|
|
if (!io.fs.existsSync(assets)) return paths;
|
|
let visited = 0;
|
|
const walk = (folder, relative, depth) => {
|
|
if (depth > 8 || visited >= 20000) return;
|
|
for (const entry of io.fs.readdirSync(folder, { withFileTypes: true })) {
|
|
if (++visited > 20000 || paths.length >= 10000) break;
|
|
if (entry.isSymbolicLink() || !/^[a-z0-9_.-]+$/.test(entry.name)) continue;
|
|
const name = `${relative}/${entry.name}`;
|
|
if (entry.isDirectory()) walk(io.path.join(folder, entry.name), name, depth + 1);
|
|
else if (entry.isFile() && entry.name.endsWith('.json')) paths.push(name);
|
|
}
|
|
};
|
|
for (const entry of io.fs.readdirSync(assets, { withFileTypes: true }).slice(0, 256)) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[a-z0-9_.-]+$/.test(entry.name)) continue;
|
|
const folder = io.path.join(assets, entry.name, 'particles');
|
|
if (io.fs.existsSync(folder) && io.fs.lstatSync(folder).isDirectory() && !io.fs.lstatSync(folder).isSymbolicLink()) {
|
|
walk(folder, `assets/${entry.name}/particles`, 0);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function loadParticleLibrary(io = desktopFiles()) {
|
|
clearParticlePreviews();
|
|
const ids = new Set(NON_PARTICLE_IDS), warnings = [], definitions = new Map();
|
|
const selected = localStorage.getItem(PARTICLE_SOURCE_KEY) || localStorage.getItem(SOUND_SOURCE_KEY);
|
|
const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : []),
|
|
...discoverPropSources(io).map((source) => source.file)]);
|
|
for (const file of paths.slice(0, 64)) {
|
|
try {
|
|
const source = openPropSource(file, io);
|
|
// The asset name identifies a Java particle; its JSON is a sprite list, not a preview definition.
|
|
for (const name of particleJsonPaths(source, io)) {
|
|
const match = name.match(/^assets\/([^/]+)\/particles\/(.+)\.json$/);
|
|
const id = match && `${match[1]}:${match[2]}`;
|
|
if (id && importResourceId(id) && ids.size < 10000) {
|
|
ids.add(id);
|
|
if (!definitions.has(id)) definitions.set(id, { file, name });
|
|
}
|
|
}
|
|
} catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); }
|
|
}
|
|
particleLibrary = { ids: sortParticleLibraryIds(ids), warnings, definitions, paths: paths.slice(0, 64),
|
|
thumbnails: new Map(), thumbnailBytes: 0 };
|
|
return particleLibrary;
|
|
}
|
|
|
|
function sortParticleLibraryIds(ids) {
|
|
return [...ids].sort((a, b) => Number(b.startsWith('needsofnature:')) - Number(a.startsWith('needsofnature:'))
|
|
|| (a < b ? -1 : a > b ? 1 : 0));
|
|
}
|
|
|
|
function particleThumbnailRows(ids, library = particleLibrary, io = null) {
|
|
const pending = new Map(), groups = new Map();
|
|
if (!library?.definitions) return ids.map((id) => ({ id, thumbnail: '' }));
|
|
for (const id of ids) {
|
|
if (library.thumbnails.has(id)) continue;
|
|
library.thumbnails.set(id, '');
|
|
const definition = library.definitions.get(id);
|
|
if (!definition || library.thumbnailBytes >= 8 * 1024 * 1024) continue;
|
|
if (!groups.has(definition.file)) groups.set(definition.file, []);
|
|
groups.get(definition.file).push({ id, name: definition.name });
|
|
}
|
|
const readSprite = (source, id, name) => {
|
|
try {
|
|
const bytes = source.read(name);
|
|
if (!bytes) return false;
|
|
// Keep thumbnails bounded and use PNG data URLs rather than exposing local file paths.
|
|
if (bytes.length >= 24 && bytes.length <= 256 * 1024
|
|
&& bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
|
&& bytes.readUInt32BE(16) > 0 && bytes.readUInt32BE(16) <= 1024
|
|
&& bytes.readUInt32BE(20) > 0 && bytes.readUInt32BE(20) <= 1024
|
|
&& library.thumbnailBytes + bytes.length <= 8 * 1024 * 1024) {
|
|
library.thumbnails.set(id, `data:image/png;base64,${bytes.toString('base64')}`);
|
|
library.thumbnailBytes += bytes.length;
|
|
}
|
|
return true;
|
|
} catch (_) { return false; }
|
|
};
|
|
// Batch the visible page by source so cross-pack sprites do not repeatedly reopen large JARs.
|
|
for (const [file, entries] of groups) {
|
|
try {
|
|
io ||= desktopFiles();
|
|
const source = openPropSource(file, io);
|
|
for (const { id, name } of entries) {
|
|
try {
|
|
const json = propJson(source, name);
|
|
const sprite = Array.isArray(json?.textures) ? json.textures[0] : null;
|
|
if (typeof sprite !== 'string') continue;
|
|
const texture = propResourcePath(sprite, 'textures/particle', '.png');
|
|
if (!readSprite(source, id, texture)) pending.set(id, texture);
|
|
} catch (_) { /* Missing or malformed assets do not prevent selecting a particle. */ }
|
|
}
|
|
} catch (_) { /* Unavailable sources retain a placeholder thumbnail. */ }
|
|
}
|
|
for (const file of library.paths) {
|
|
if (!pending.size) break;
|
|
try {
|
|
const source = openPropSource(file, io);
|
|
for (const [id, name] of pending) if (readSprite(source, id, name)) pending.delete(id);
|
|
} catch (_) { /* Try the remaining asset sources. */ }
|
|
}
|
|
return ids.map((id) => ({ id, thumbnail: library.thumbnails.get(id) || '' }));
|
|
}
|
|
|
|
function availableParticleLocators() {
|
|
const locators = typeof Locator !== 'undefined' ? Locator.all
|
|
: typeof Outliner !== 'undefined' ? Outliner.elements?.filter((element) => element.type === 'locator') : [];
|
|
return uniqueTrimmedStrings((locators || []).map((locator) => locator.name)).sort();
|
|
}
|
|
|
|
function buildParticlePreview(id, texture) {
|
|
const kind = PARTICLE_PREVIEW_KINDS[id];
|
|
if (!kind || !texture) return null;
|
|
const bytes = Buffer.from(texture.split(',')[1], 'base64');
|
|
const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20);
|
|
const size = kind === 'heart' ? 0.185 : kind === 'fabric' ? 0.19 : 0.12;
|
|
const lifetime = kind === 'heart' ? 0.8 : kind === 'fabric' ? 6 : 4;
|
|
const uv = { texture_width: width, texture_height: height, uv: [0, 0], uv_size: [width, height] };
|
|
if (kind === 'fabric' && height % 5 === 0) {
|
|
delete uv.uv; delete uv.uv_size;
|
|
uv.flipbook = { base_UV: [0, 0], size_UV: [width, height / 5], step_UV: [0, height / 5],
|
|
frames_per_second: 5, max_frame: 5, stretch_to_lifetime: false, loop: true };
|
|
}
|
|
const components = {
|
|
'minecraft:emitter_rate_instant': { num_particles: 1 },
|
|
'minecraft:emitter_lifetime_once': { active_time: lifetime },
|
|
'minecraft:emitter_local_space': { position: false, rotation: false, velocity: false },
|
|
'minecraft:emitter_shape_point': { offset: [0, -1 / 16, 0], direction: [0, 1, 0] },
|
|
'minecraft:particle_lifetime_expression': { max_lifetime: lifetime },
|
|
'minecraft:particle_initial_speed': kind === 'heart' ? 2 : 0,
|
|
'minecraft:particle_motion_dynamic': { linear_acceleration: [0, kind === 'falling' ? -2.4 : kind === 'fabric' ? -0.16 : 0, 0],
|
|
linear_drag_coefficient: kind === 'heart' ? 3 : kind === 'fabric' ? 4 : 0.4 },
|
|
'minecraft:particle_appearance_billboard': { size: [size, size], facing_camera_mode: 'rotate_xyz', uv },
|
|
};
|
|
if (kind === 'liquid') {
|
|
delete components['minecraft:particle_motion_dynamic'];
|
|
components['minecraft:particle_motion_parametric'] = { relative_position: [0,
|
|
'v.particle_age < 1 ? 0 : -1.2 * (v.particle_age - 1) * (v.particle_age - 1)', 0] };
|
|
}
|
|
return { format_version: '1.10.0', particle_effect: {
|
|
description: { identifier: id, basic_render_parameters: { material: 'particles_alpha', texture: 'editor_preview' } }, components,
|
|
} };
|
|
}
|
|
|
|
function clearParticlePreviews() {
|
|
for (const point of particlePreviewPoints) {
|
|
if (point.file === point[PARTICLE_PREVIEW_PROPERTY] || point.file?.startsWith(PARTICLE_PREVIEW_PREFIX)) point.file = '';
|
|
delete point[PARTICLE_PREVIEW_PROPERTY];
|
|
}
|
|
particlePreviewPoints.clear();
|
|
for (const [key, effect] of particlePreviewEffects) {
|
|
for (const emitter of Object.values(effect.emitters)) emitter.delete();
|
|
effect.config.texture?.dispose();
|
|
if (typeof Animator !== 'undefined' && Animator.particle_effects?.[key] === effect) delete Animator.particle_effects[key];
|
|
}
|
|
particlePreviewEffects.clear();
|
|
}
|
|
|
|
function mapAnimationParticles(animations) {
|
|
if (typeof Animator === 'undefined' || !Animator.particle_effects || typeof Wintersky === 'undefined'
|
|
|| !Wintersky.Config || typeof WinterskyScene === 'undefined') return;
|
|
const points = [];
|
|
const locators = new Set(availableParticleLocators());
|
|
for (const animation of animations.filter(Boolean)) {
|
|
for (const frame of animation.animators?.effects?.particle || []) {
|
|
for (const point of frame.data_points || []) {
|
|
const generated = point.file === point[PARTICLE_PREVIEW_PROPERTY] || point.file?.startsWith(PARTICLE_PREVIEW_PREFIX);
|
|
if (point.file && !generated) continue;
|
|
if (!particlePreviewsEnabled || (typeof Format !== 'undefined' && Format.id !== 'geckolib_model')
|
|
|| !locators.has(point.locator) || !Object.hasOwn(PARTICLE_PREVIEW_KINDS, point.effect)) {
|
|
if (generated) point.file = '';
|
|
continue;
|
|
}
|
|
points.push(point);
|
|
}
|
|
}
|
|
}
|
|
if (!points.length) return;
|
|
if (!particleLibrary) {
|
|
try { loadParticleLibrary(); } catch (_) { return; }
|
|
}
|
|
const ids = uniqueTrimmedStrings(points.map((point) => point.effect));
|
|
for (const { id, thumbnail } of particleThumbnailRows(ids)) {
|
|
const key = `${PARTICLE_PREVIEW_PREFIX}${id}`;
|
|
if (particlePreviewEffects.has(key) && Animator.particle_effects[key] === particlePreviewEffects.get(key)) continue;
|
|
const json = buildParticlePreview(id, thumbnail);
|
|
if (!json) continue;
|
|
// Register in memory: loadParticleEmitter would install a filesystem watcher on a virtual file.
|
|
const config = new Wintersky.Config(WinterskyScene, { preview_texture: thumbnail });
|
|
config.setFromJSON(json);
|
|
const effect = { config, emitters: {} };
|
|
Animator.particle_effects[key] = effect; particlePreviewEffects.set(key, effect);
|
|
}
|
|
for (const point of points) {
|
|
const key = `${PARTICLE_PREVIEW_PREFIX}${point.effect}`;
|
|
point.file = particlePreviewEffects.has(key) ? key : '';
|
|
point[PARTICLE_PREVIEW_PROPERTY] = point.file;
|
|
particlePreviewPoints.add(point);
|
|
}
|
|
}
|
|
|
|
function setParticlePreviewsEnabled(enabled) {
|
|
particlePreviewsEnabled = !!enabled;
|
|
localStorage.setItem(PARTICLE_PREVIEWS_KEY, String(particlePreviewsEnabled));
|
|
if (particlePreviewToggle) { particlePreviewToggle.value = particlePreviewsEnabled; particlePreviewToggle.updateEnabledState?.(); }
|
|
clearParticlePreviews();
|
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
|
|
if (typeof Animator !== 'undefined') Animator.preview?.();
|
|
}
|
|
|
|
function applyLibraryParticle(id, insert = false, locator = null) {
|
|
if (!importResourceId(id)) throw new Error('Invalid particle ID.');
|
|
if (locator !== null && !availableParticleLocators().includes(locator)) throw new Error('The selected locator is no longer available in this project.');
|
|
const animation = typeof Animation === 'undefined' ? null : Animation.selected;
|
|
if (!animation) throw new Error('Select an animation first.');
|
|
const selected = (Timeline.selected || []).filter((frame) => frame.channel === 'particle' && frame.animator?.animation === animation);
|
|
if (!insert && selected.length) {
|
|
Undo.initEdit({ keyframes: selected });
|
|
for (const frame of selected) {
|
|
const point = frame.data_points[0];
|
|
if (point.effect !== id) point.file = '';
|
|
point.effect = id;
|
|
if (locator !== null) point.locator = locator;
|
|
}
|
|
Undo.finishEdit('Apply particle from library');
|
|
} else {
|
|
const effects = animation.animators.effects || (animation.animators.effects = new EffectAnimator(animation));
|
|
const keyframes = effects.particle.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001);
|
|
Undo.initEdit({ keyframes });
|
|
let frame = keyframes[0];
|
|
const point = { effect: id, ...(locator !== null ? { locator } : {}) };
|
|
if (frame) frame.extend({ data_points: [...frame.data_points.map((point) => point.getUndoCopy()), point] });
|
|
else {
|
|
frame = effects.createKeyframe({ data_points: [point] }, Timeline.time, 'particle', false, false);
|
|
keyframes.push(frame);
|
|
}
|
|
effects.select(); frame.select();
|
|
Undo.finishEdit('Insert particle from library', { keyframes });
|
|
}
|
|
mapAnimationParticles([animation]);
|
|
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
|
}
|
|
|
|
function showParticleLibrary() {
|
|
if (!particleLibrary) {
|
|
try { loadParticleLibrary(); }
|
|
catch (error) { particleLibrary = { ids: sortParticleLibraryIds(NON_PARTICLE_IDS), warnings: [error.message] }; }
|
|
}
|
|
const locators = availableParticleLocators();
|
|
const selectedFrames = (typeof Timeline === 'undefined' ? [] : Timeline.selected || [])
|
|
.filter((frame) => frame.channel === 'particle' && frame.animator?.animation === Animation.selected);
|
|
const currentLocators = uniqueTrimmedStrings(selectedFrames.map((frame) => frame.data_points[0]?.locator || ''));
|
|
const defaultLocator = selectedFrames.length && currentLocators.length === 1 && locators.includes(currentLocators[0])
|
|
&& selectedFrames.every((frame) => frame.data_points[0]?.locator === currentLocators[0])
|
|
? currentLocators[0] : selectedFrames.length ? '' : locators[0] || '';
|
|
particleLibraryDialog?.delete();
|
|
particleLibraryDialog = new Dialog({ id: 'animationdirector_particle_library', title: 'NoN Particle Library', width: 700,
|
|
buttons: ['Close'], component: {
|
|
data() { return { query: '', category: 'all', selected: '', page: 0,
|
|
ids: particleLibrary.ids, locators, locator: defaultLocator, thumbnailRevision: 0,
|
|
previews: particlePreviewsEnabled,
|
|
warnings: particleLibrary.warnings.join('\n'), error: '' }; },
|
|
computed: {
|
|
categories() { return [...new Set(this.ids.map((id) => id.split(':')[0]))].sort(); },
|
|
rows() { return this.ids.filter((id) => (this.category === 'all' || id.startsWith(`${this.category}:`))
|
|
&& id.toLowerCase().includes(this.query.toLowerCase())); },
|
|
visibleRows() {
|
|
// Reloading replaces the asset cache, even if the list of IDs stays unchanged.
|
|
void this.thumbnailRevision;
|
|
return particleThumbnailRows(this.rows.slice(this.page * 100, (this.page + 1) * 100));
|
|
},
|
|
},
|
|
watch: { query() { this.page = 0; }, category() { this.page = 0; } },
|
|
methods: {
|
|
reload() {
|
|
try { loadParticleLibrary(); this.ids = particleLibrary.ids; this.warnings = particleLibrary.warnings.join('\n');
|
|
this.page = 0; this.thumbnailRevision++; this.error = '';
|
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all); }
|
|
catch (error) { this.error = error.message; }
|
|
},
|
|
folder() {
|
|
try { const folder = Blockbench.pickDirectory({ title: 'Select folder containing assets', resource_id: PARTICLE_SOURCE_KEY });
|
|
if (folder) { localStorage.setItem(PARTICLE_SOURCE_KEY, folder); this.reload(); } }
|
|
catch (error) { this.error = error.message; }
|
|
},
|
|
jar() {
|
|
Filesystem.importFile({ type: 'Particle assets', extensions: ['jar', 'zip'], readtype: 'none', multiple: false }, (files) => {
|
|
if (files?.[0]?.path) { localStorage.setItem(PARTICLE_SOURCE_KEY, files[0].path); this.reload(); }
|
|
});
|
|
},
|
|
apply(insert) { try { applyLibraryParticle(this.selected, insert, this.locator || null); this.error = ''; } catch (error) { this.error = error.message; } },
|
|
togglePreviews() { setParticlePreviewsEnabled(this.previews); },
|
|
},
|
|
template: `<div style="padding:12px">
|
|
<div class="bar flex" style="gap:6px"><button @click="jar">Select JAR / ZIP</button><button @click="folder">Select Assets Folder</button><button @click="reload">Refresh</button></div>
|
|
<p><label><input type="checkbox" v-model="previews" @change="togglePreviews"> Particle Previews</label></p>
|
|
<p class="subtle">Includes NoN particles, remembered Pack Folders, and locally available Minecraft assets. Select a mod JAR to add its particles.</p>
|
|
<div class="bar flex" style="gap:8px;margin:10px 0"><input v-model="query" placeholder="Search particle IDs" style="flex:1" aria-label="Search particle IDs"><select v-model="category"><option value="all">All particles</option><option v-for="namespace in categories" :key="namespace" :value="namespace">{{namespace}}</option></select></div>
|
|
<div style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)">
|
|
<div v-for="row in visibleRows" :key="row.id" @click="selected=row.id" :style="{background:selected===row.id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer',display:'flex',alignItems:'center',gap:'10px'}">
|
|
<span style="width:28px;height:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--color-back);border:1px solid var(--color-border)">
|
|
<img v-if="row.thumbnail" :src="row.thumbnail" alt="Particle sprite" @error="$event.target.style.display='none'" style="width:24px;height:24px;object-fit:contain;image-rendering:pixelated">
|
|
<span v-else style="opacity:.35">?</span>
|
|
</span><span>{{row.id}}</span>
|
|
</div>
|
|
</div>
|
|
<div class="bar flex" style="gap:6px"><button @click="page--" :disabled="page===0">Previous</button><span>{{rows.length}} results</span><button @click="page++" :disabled="(page+1)*100>=rows.length">Next</button></div>
|
|
<div class="bar flex" style="gap:8px;margin-top:12px"><label for="animationdirector_particle_locator">Locator</label><select id="animationdirector_particle_locator" v-model="locator" style="flex:1"><option value="">Keep existing / unset</option><option v-for="name in locators" :key="name" :value="name">{{name}}</option></select></div>
|
|
<div class="bar flex" style="gap:6px;margin-top:12px"><button @click="apply(false)" :disabled="!selected">Apply to Selected / Insert</button><button @click="apply(true)" :disabled="!selected">Insert at Playhead</button></div>
|
|
<p class="subtle">Thumbnails show the first sprite, not particle motion or in-game tint. Parameterized particles require a NoN addon resolver; their ID alone may not spawn them.</p>
|
|
<p class="subtle">Timeline previews support NoN liquid, falling liquid, small hearts, and ripped fabric. Select NoN assets to load their textures. Motion is approximate; world collisions, liquid color and gameplay are not simulated.</p>
|
|
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
|
|
</div>`,
|
|
},
|
|
});
|
|
particleLibraryDialog.show();
|
|
}
|
|
|
|
function showSoundLibrary() {
|
|
stopSoundAudition();
|
|
ensureSoundLibrary();
|
|
soundLibraryDialog?.delete();
|
|
soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'NoN Sound Library', width: 700,
|
|
buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition,
|
|
component: {
|
|
data() { return { query: '', category: 'all', selected: '', page: 0,
|
|
ids: [...soundLibrary.events.keys()].sort(), warnings: soundLibrary.warnings.join('\n'), error: '' }; },
|
|
computed: {
|
|
rows() {
|
|
const cues = ['reactiveimpact', 'reactiveimpact_silent', 'impactdry', 'impactwet', 'birth'];
|
|
return [...cues.map((id) => ({ id, category: 'Gameplay cues' })), ...this.ids.map((id) => ({ id, category: 'Sound events' }))]
|
|
.filter((entry) => !SOUND_SELECTION_BLACKLIST.has(entry.id)
|
|
&& (this.category === 'all' || entry.category === this.category) && entry.id.toLowerCase().includes(this.query.toLowerCase()));
|
|
},
|
|
visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); },
|
|
},
|
|
watch: { query() { this.page = 0; }, category() { this.page = 0; } },
|
|
methods: {
|
|
reload() {
|
|
try { loadSoundLibrary(); this.ids = [...soundLibrary.events.keys()].sort(); this.warnings = soundLibrary.warnings.join('\n');
|
|
this.error = ''; mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); }
|
|
catch (error) { this.error = error.message; }
|
|
},
|
|
folder() {
|
|
try { const folder = Blockbench.pickDirectory({ title: 'Select folder containing assets', resource_id: SOUND_SOURCE_KEY });
|
|
if (folder) { localStorage.setItem(SOUND_SOURCE_KEY, folder); this.reload(); } }
|
|
catch (error) { this.error = error.message; }
|
|
},
|
|
jar() {
|
|
Filesystem.importFile({ type: 'Sound assets', extensions: ['jar', 'zip'], readtype: 'none', multiple: false }, (files) => {
|
|
if (files?.[0]?.path) { localStorage.setItem(SOUND_SOURCE_KEY, files[0].path); this.reload(); }
|
|
});
|
|
},
|
|
preview() {
|
|
stopSoundAudition(); this.error = '';
|
|
const file = resolveSoundAudio(this.selected, ensureSoundLibrary(), new Set(), typeof Animation === 'undefined' ? null : Animation.selected);
|
|
if (!file) { this.error = this.selected === 'reactiveimpact_silent' ? 'Silent cue: gameplay effects only.' : 'No audio found. Select the NoN JAR or an extracted assets folder.'; return; }
|
|
soundAudition = new Audio(file);
|
|
soundAudition.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100));
|
|
soundAudition.play().catch((error) => { this.error = error.message; });
|
|
},
|
|
stop: stopSoundAudition,
|
|
apply(insert) { try { applyLibrarySound(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } },
|
|
},
|
|
template: `<div style="padding:12px">
|
|
<div class="bar flex" style="gap:6px"><button @click="jar">Select NoN JAR / ZIP</button><button @click="folder">Select Assets Folder</button><button @click="reload">Refresh</button></div>
|
|
<p class="subtle">Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.</p>
|
|
<div class="bar flex" style="gap:8px;margin:10px 0"><input v-model="query" placeholder="Search sound IDs" style="flex:1" aria-label="Search sound IDs"><select v-model="category"><option value="all">All sounds</option><option>Sound events</option><option>Gameplay cues</option></select></div>
|
|
<p class="subtle">Gameplay cues and random events use representative audio, not a gameplay simulation.</p>
|
|
<div style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)">
|
|
<div v-for="row in visibleRows" :key="row.category+row.id" @click="selected=row.id;preview()" :style="{background:selected===row.id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer'}"><span>{{row.id}}</span><small style="float:right;opacity:.6">{{row.category}}</small></div>
|
|
</div>
|
|
<div class="bar flex" style="gap:6px"><button @click="page--" :disabled="page===0">Previous</button><span>{{rows.length}} results</span><button @click="page++" :disabled="(page+1)*100>=rows.length">Next</button></div>
|
|
<div class="bar flex" style="gap:6px;margin-top:12px"><button @click="preview" :disabled="!selected">Play</button><button @click="stop">Stop</button><button @click="apply(false)" :disabled="!selected">Apply to Selected / Insert</button><button @click="apply(true)" :disabled="!selected">Insert at Playhead</button></div>
|
|
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
|
|
</div>`,
|
|
},
|
|
});
|
|
soundLibraryDialog.show();
|
|
}
|
|
|
|
function isBrokenFiguraSoundWrapper(callback) {
|
|
// Figura Format 0.1.5 discards in_loop. Do not patch native or corrected implementations.
|
|
return typeof callback === 'function' && /^function\s*\(\s*\)\s*\{\s*if\s*\(\s*Format\s*===\s*format\s*\)\s*return\s*;?\s*displayFrame\.call\(\s*this\s*\)\s*;?\s*\}$/.test(Function.prototype.toString.call(callback));
|
|
}
|
|
|
|
function removeFiguraSoundMedia(media) {
|
|
for (const list of [Timeline.playing_sounds, Timeline.paused_sounds]) {
|
|
const index = list?.indexOf(media) ?? -1;
|
|
if (index >= 0) list.splice(index, 1);
|
|
}
|
|
figuraSoundMedia.delete(media);
|
|
}
|
|
|
|
function playFiguraCompatSounds(animator, inLoop, states) {
|
|
const time = animator.animation?.time;
|
|
if (!Number.isFinite(time)) return;
|
|
const previous = states.get(animator);
|
|
states.set(animator, { time, includeStart: false });
|
|
if (!inLoop || animator.muted?.sound || !Timeline.playing) return;
|
|
const wrapped = previous && time < previous.time;
|
|
const rangeStart = Timeline.custom_range?.[0] || 0;
|
|
const rangeEnd = Math.min(animator.animation.length || Infinity, Timeline.custom_range?.[1] || Infinity);
|
|
for (const frame of animator.sound || []) {
|
|
const crossed = !previous ? frame.time === time : wrapped
|
|
? (frame.time > previous.time && frame.time <= rangeEnd) || (frame.time >= rangeStart && frame.time <= time)
|
|
: frame.time <= time && (frame.time > previous.time || (previous.includeStart && frame.time === previous.time));
|
|
const file = frame.data_points?.[0]?.file;
|
|
if (!crossed || !file) continue;
|
|
const existing = Timeline.playing_sounds.filter((media) => media.keyframe_id === frame.uuid);
|
|
// A sound resumed by Blockbench's startPreviousSounds must not be played twice.
|
|
if (existing.length && !wrapped) continue;
|
|
for (const media of existing) { media.pause(); removeFiguraSoundMedia(media); }
|
|
try {
|
|
const media = new Audio(file);
|
|
media.keyframe_id = frame.uuid;
|
|
media.playbackRate = Math.max(0.1, Math.min(4, (Timeline.playback_speed || 100) / 100));
|
|
media.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100));
|
|
media.onended = () => removeFiguraSoundMedia(media);
|
|
Timeline.playing_sounds.push(media); figuraSoundMedia.add(media);
|
|
media.play().catch((error) => { removeFiguraSoundMedia(media); console.warn('AnimationDirector sound preview failed:', error); });
|
|
} catch (error) { console.warn('AnimationDirector sound preview failed:', error); }
|
|
}
|
|
}
|
|
|
|
function installFiguraSoundCompat() {
|
|
if (typeof EffectAnimator === 'undefined') return;
|
|
const original = EffectAnimator.prototype.displayFrame;
|
|
if (!isBrokenFiguraSoundWrapper(original)) return;
|
|
const states = new WeakMap();
|
|
const wrapper = function (...args) {
|
|
const result = original.apply(this, args);
|
|
if (figuraSoundCompatEnabled && typeof Format !== 'undefined' && Format.id === 'geckolib_model') {
|
|
playFiguraCompatSounds(this, args[0], states);
|
|
}
|
|
return result;
|
|
};
|
|
figuraSoundPatches.push({ original, wrapper, states });
|
|
EffectAnimator.prototype.displayFrame = wrapper;
|
|
}
|
|
|
|
function beginFiguraSoundPlayback() {
|
|
installFiguraSoundCompat();
|
|
if (typeof Animation === 'undefined') return;
|
|
mapAnimationSounds(Animation.all);
|
|
mapAnimationParticles(Animation.all);
|
|
for (const animation of Animation.all) {
|
|
const effects = animation.animators?.effects;
|
|
if (effects) for (const patch of figuraSoundPatches) {
|
|
patch.states.set(effects, { time: animation.time, includeStart: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeFiguraSoundCompat() {
|
|
figuraSoundCompatEnabled = false;
|
|
for (const media of [...figuraSoundMedia]) { media.pause(); removeFiguraSoundMedia(media); }
|
|
for (const patch of figuraSoundPatches.slice().reverse()) {
|
|
if (EffectAnimator.prototype.displayFrame === patch.wrapper) EffectAnimator.prototype.displayFrame = patch.original;
|
|
}
|
|
figuraSoundPatches.length = 0;
|
|
}
|
|
|
|
function refreshSoundLibraryPanel() {
|
|
installFiguraSoundCompat();
|
|
if (typeof document !== 'undefined') {
|
|
const panel = document.getElementById('panel_keyframe');
|
|
const existing = document.getElementById('animationdirector_sound_library_button');
|
|
const soundSelection = typeof Timeline !== 'undefined' && Timeline.selected?.length > 0
|
|
&& Timeline.selected.every((keyframe) => keyframe.channel === 'sound');
|
|
if (!soundSelection || Blockbench.isWeb) {
|
|
existing?.remove();
|
|
} else if (panel && !existing) {
|
|
const button = document.createElement('button');
|
|
button.id = 'animationdirector_sound_library_button'; button.textContent = 'NoN Sound Library';
|
|
button.style.cssText = 'margin:8px;width:calc(100% - 16px)';
|
|
button.addEventListener('click', showSoundLibrary); panel.appendChild(button);
|
|
}
|
|
const particleButton = document.getElementById('animationdirector_particle_library_button');
|
|
const particleSelection = typeof Timeline !== 'undefined' && Timeline.selected?.length > 0
|
|
&& Timeline.selected.every((keyframe) => keyframe.channel === 'particle');
|
|
if (!particleSelection || Blockbench.isWeb) particleButton?.remove();
|
|
else if (panel && !particleButton) {
|
|
const button = document.createElement('button');
|
|
button.id = 'animationdirector_particle_library_button'; button.textContent = 'NoN Particle Library';
|
|
button.style.cssText = 'margin:8px;width:calc(100% - 16px)';
|
|
button.addEventListener('click', showParticleLibrary); panel.appendChild(button);
|
|
}
|
|
}
|
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]);
|
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : [Animation.selected]);
|
|
}
|
|
|
|
Plugin.register(PLUGIN_ID, {
|
|
title: 'AnimationDirector',
|
|
author: 'L1Z0',
|
|
icon: 'diversity_4',
|
|
description: 'Multi-actor animation in Blockbench without bone-name clashes.',
|
|
about: `AnimationDirector is a Blockbench plugin for creating multi-actor animations for Needs of Nature.
|
|
|
|
Build, preview, and export multi-actor animations with tools for actor models, animation stages, block requirements, item props, and sound cues. Import existing pack animations or create your own, then configure their animation definitions directly in Blockbench.
|
|
|
|
**Requirement:** Install and enable the GeckoLib Blockbench plugin.
|
|
|
|
**Features**
|
|
- Create multi-actor animations in one project without bone-name conflicts.
|
|
- Import whole animations from extracted packs, including actor models, textures, animation stages, and animation definitions. <sup>1</sup>
|
|
- Configure and export animation definitions through a guided interface, with settings saved in the project.
|
|
- Edit block requirements with live previews of required blocks and clearance areas.
|
|
- Preview item props using Minecraft assets or custom textures. <sup>1</sup>
|
|
- Browse and audition sounds with the NoN Sound Library.
|
|
- Browse particle sprites and assign cues and locators with the NoN Particle Library, including approximate timeline previews for supported NoN particles. <sup>1</sup>
|
|
- Export multi-actor, multi-stage animations and GeckoLib models.
|
|
- Replace actor models while preserving matching animation tracks.
|
|
- Transfer added geometry into vanilla CEM presets to create JEM models, with optional energy-dependent visibility. <sup>2</sup>
|
|
- Assign actor-specific textures and bone texture overrides, including support for mixed texture sizes.
|
|
- Adds timeline tools to simplify looping animations and working across multiple stages.
|
|
|
|
**Addon Notes**
|
|
|
|
<sup>1</sup> Asset Browser - optional; its cached Minecraft assets can be used for textures, item previews, and the particle library.
|
|
|
|
<sup>2</sup> CEM Template Loader - required for JEM creation.
|
|
|
|
Features without a reference need no additional addon. Whole-animation import and local asset tools require desktop Blockbench. The sound library uses locally available NoN/pack sound files; NoN particle previews require their texture assets from a NoN JAR or extracted assets folder.`,
|
|
version: '2.0',
|
|
min_version: '5.2.1',
|
|
variant: 'both',
|
|
onload() {
|
|
autoplayStagesAction = new Toggle('animationdirector_autoplay_stages', {
|
|
name: 'Autoplay Stages', icon: 'resume', color: '#b8a6cc', category: 'animation',
|
|
description: 'Automatically play the next stage when a non-looping stage finishes. Looping stages stay on their current clip.',
|
|
default: false, save_on_restart: true,
|
|
onChange: updateAutoplayIconColor,
|
|
condition: () => typeof Format !== 'undefined' && Format.id === 'geckolib_model',
|
|
});
|
|
if (typeof Toolbars !== 'undefined') {
|
|
const toolbar = Toolbars.animations;
|
|
if (toolbar) {
|
|
const length = toolbar.children.findIndex((item) => item === 'slider_animation_length' || item?.id === 'slider_animation_length');
|
|
toolbar.add(autoplayStagesAction, length >= 0 ? length + 1 : 0);
|
|
}
|
|
}
|
|
updateAutoplayIconColor();
|
|
installStageAutoplay();
|
|
previousStagePoseAction = new Action('animationdirector_previous_stage_pose', {
|
|
name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation',
|
|
description: 'Copy the final actor pose of the previous stage into time 0.',
|
|
click() {
|
|
try { copyPreviousStagePose(); }
|
|
catch (error) { Blockbench.showMessageBox({ title: 'Copy Previous Stage Pose', message: error.message }); }
|
|
},
|
|
});
|
|
loopPoseAction = new Action('animationdirector_first_pose_to_end', {
|
|
name: 'Copy First Pose to End', icon: 'step_over', color: '#b8a6cc', category: 'animation',
|
|
description: 'Copy the first actor pose to the clip endpoint for looping.',
|
|
click() {
|
|
try { copyFirstPoseToEnd(); }
|
|
catch (error) { Blockbench.showMessageBox({ title: 'Copy First Pose to End', message: error.message }); }
|
|
},
|
|
});
|
|
if (typeof Toolbars !== 'undefined' && Toolbars.timeline) {
|
|
const toolbar = Toolbars.timeline;
|
|
const marker = toolbar.children.findIndex((item) => item === 'add_marker' || item?.id === 'add_marker');
|
|
toolbar.add(previousStagePoseAction, marker >= 0 ? marker + 1 : 0);
|
|
toolbar.add(loopPoseAction, toolbar.children.indexOf(previousStagePoseAction) + 1);
|
|
}
|
|
for (const event of POSE_BUTTON_EVENTS) Blockbench.on(event, refreshPreviousStagePoseButton);
|
|
previousStagePoseAction.node?.addEventListener('mouseenter', refreshPreviousStagePoseButton);
|
|
refreshPreviousStagePoseButton();
|
|
for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.on(event, refreshLoopPoseButton);
|
|
loopPoseAction.node?.addEventListener('mouseenter', refreshLoopPoseButton);
|
|
refreshLoopPoseButton();
|
|
if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') {
|
|
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
|
|
exposed: false, condition: (point) => point.keyframe.channel === 'sound',
|
|
});
|
|
particlePreviewProperty = new Property(KeyframeDataPoint, 'string', PARTICLE_PREVIEW_PROPERTY, {
|
|
exposed: false, condition: (point) => point.keyframe.channel === 'particle',
|
|
});
|
|
}
|
|
particlePreviewsEnabled = localStorage.getItem(PARTICLE_PREVIEWS_KEY) !== 'false';
|
|
particlePreviewToggle = new Toggle('animationdirector_particle_previews', {
|
|
name: 'Particle Previews', icon: 'grain', category: 'animation', default: particlePreviewsEnabled,
|
|
onChange: setParticlePreviewsEnabled, condition: () => !Blockbench.isWeb,
|
|
});
|
|
Blockbench.on('close_project', clearParticlePreviews);
|
|
soundLibraryAction = new Action('animationdirector_sound_library', {
|
|
name: 'NoN Sound Library', icon: 'library_music', category: 'animation',
|
|
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
|
|
click: showSoundLibrary,
|
|
condition: () => !Blockbench.isWeb,
|
|
});
|
|
soundLibraryListener = refreshSoundLibraryPanel;
|
|
particleLibraryAction = new Action('animationdirector_particle_library', {
|
|
name: 'NoN Particle Library', icon: 'grain', category: 'animation',
|
|
description: 'Browse NoN and asset particle IDs and add particle cues to the timeline.',
|
|
click: showParticleLibrary, condition: () => !Blockbench.isWeb,
|
|
});
|
|
Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener);
|
|
figuraSoundCompatEnabled = true;
|
|
figuraSoundPlayListener = beginFiguraSoundPlayback;
|
|
Blockbench.on('timeline_play', figuraSoundPlayListener);
|
|
refreshSoundLibraryPanel();
|
|
if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
|
|
if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) {
|
|
actorNamesProjectProperty = new Property(ModelProject, 'string', ACTOR_NAMES_PROPERTY, {
|
|
default: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[ACTOR_TEXTURES_PROPERTY]) {
|
|
actorTexturesProjectProperty = new Property(ModelProject, 'string', ACTOR_TEXTURES_PROPERTY, {
|
|
default: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[ACTOR_UV_BASIS_PROPERTY]) {
|
|
actorUvBasisProjectProperty = new Property(ModelProject, 'string', ACTOR_UV_BASIS_PROPERTY, { default: '{}' });
|
|
}
|
|
if (!ModelProject.properties[BONE_TEXTURE_OVERRIDES_PROPERTY]) {
|
|
boneTextureOverridesProjectProperty = new Property(ModelProject, 'string', BONE_TEXTURE_OVERRIDES_PROPERTY, {
|
|
default: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[ACTOR_ANIMDEF_METADATA_PROPERTY]) {
|
|
actorAnimdefMetadataProjectProperty = new Property(ModelProject, 'string', ACTOR_ANIMDEF_METADATA_PROPERTY, {
|
|
default: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[ANIMDEF_ANIMATORS_PROPERTY]) {
|
|
animdefAnimatorsProjectProperty = new Property(ModelProject, 'string', ANIMDEF_ANIMATORS_PROPERTY, {
|
|
default: '',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[ANIMDEF_DEFINITIONS_PROPERTY]) {
|
|
animdefDefinitionsProjectProperty = new Property(ModelProject, 'string', ANIMDEF_DEFINITIONS_PROPERTY, {
|
|
default: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[BLOCK_REQUIREMENTS_PROPERTY]) {
|
|
blockRequirementsProjectProperty = new Property(ModelProject, 'string', BLOCK_REQUIREMENTS_PROPERTY, {
|
|
default: '{}',
|
|
});
|
|
}
|
|
if (!ModelProject.properties[PROP_PREVIEWS_PROPERTY]) {
|
|
propProjectProperty = new Property(ModelProject, 'string', PROP_PREVIEWS_PROPERTY, { default: '{}' });
|
|
}
|
|
}
|
|
|
|
blockGuidePreviewListener = ({ element }) => applyBlockRequirementGuideMaterial(element);
|
|
Cube.preview_controller.on('setup update_faces', blockGuidePreviewListener);
|
|
for (const cube of Cube.all) applyBlockRequirementGuideMaterial(cube);
|
|
blockGuideProjectListener = refreshBlockGuideFaces;
|
|
Blockbench.on('select_project', blockGuideProjectListener);
|
|
refreshBlockGuideFaces();
|
|
blockRequirementUndoSaveListener = ({ save, aspects }) => {
|
|
if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}';
|
|
if (aspects.animdef_edit) save[ANIMDEF_DEFINITIONS_PROPERTY] = Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}';
|
|
if (aspects.prop_preview_edit) save[PROP_PREVIEWS_PROPERTY] = Project[PROP_PREVIEWS_PROPERTY] || '{}';
|
|
if (aspects.actor_uv_edit) save[ACTOR_UV_BASIS_PROPERTY] = Project[ACTOR_UV_BASIS_PROPERTY] || '{}';
|
|
if (aspects.actor_texture_edit) save[ACTOR_TEXTURES_PROPERTY] = Project[ACTOR_TEXTURES_PROPERTY] || '{}';
|
|
if (aspects.actor_preferences_edit) {
|
|
for (const property of [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, ACTOR_ANIMDEF_METADATA_PROPERTY,
|
|
BONE_TEXTURE_OVERRIDES_PROPERTY, ACTOR_UV_BASIS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY,
|
|
PROP_PREVIEWS_PROPERTY]) save[property] = Project[property] || '{}';
|
|
}
|
|
if (aspects.automatic_animation_import) {
|
|
for (const property of IMPORT_METADATA) save[property] = Project[property] ?? (property === ANIMDEF_ANIMATORS_PROPERTY ? '' : '{}');
|
|
}
|
|
};
|
|
blockRequirementUndoLoadListener = ({ save }) => {
|
|
if (save[BLOCK_REQUIREMENTS_PROPERTY] !== undefined) Project[BLOCK_REQUIREMENTS_PROPERTY] = save[BLOCK_REQUIREMENTS_PROPERTY];
|
|
if (save[ANIMDEF_DEFINITIONS_PROPERTY] !== undefined) Project[ANIMDEF_DEFINITIONS_PROPERTY] = save[ANIMDEF_DEFINITIONS_PROPERTY];
|
|
for (const property of IMPORT_METADATA) {
|
|
if (save[property] !== undefined) Project[property] = save[property];
|
|
}
|
|
scheduleActorTexturePreviewApply();
|
|
void refreshPropPreviews(true);
|
|
};
|
|
Blockbench.on('create_undo_save', blockRequirementUndoSaveListener);
|
|
Blockbench.on('load_undo_save', blockRequirementUndoLoadListener);
|
|
propListener = () => { void refreshPropPreviews().catch((error) => {
|
|
console.warn('Could not refresh item prop preview:', error);
|
|
}); };
|
|
Blockbench.on('select_animation select_project load_project unselect_project undo redo render_frame', propListener);
|
|
|
|
propAction = new Action('animationdirector_item_props', {
|
|
condition: () => !Blockbench.isWeb,
|
|
name: 'Item Props', icon: 'interests', description: 'Preview item props on actor bones and configure default/per-stage animdef props.',
|
|
click: showPropDialog,
|
|
});
|
|
|
|
blockRequirementAction = new Action('animationdirector_add_block_requirement', {
|
|
name: 'Block Requirements',
|
|
description: 'Open the live block requirement panel with blue structure / red clearance guides.',
|
|
icon: 'view_in_ar',
|
|
click: addBlockRequirement,
|
|
});
|
|
|
|
importAction = new Action('import_actor_gecko_json', {
|
|
name: 'Import Actor',
|
|
description: 'Import a GeckoLib/Bedrock geometry JSON into the current project and prefix bones with actorN_.',
|
|
icon: 'person_add',
|
|
click: importActorGeckoJson,
|
|
});
|
|
|
|
replaceActorAction = new Action('replace_actor_gecko_json', {
|
|
name: 'Replace Actor Model',
|
|
description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.',
|
|
icon: 'swap_horiz',
|
|
click: replaceActorGeckoJson,
|
|
});
|
|
|
|
importConjoinedAnimationAction = new Action('import_conjoined_actor_animation', {
|
|
name: 'Import Animation',
|
|
description: 'Import one conjoined AnimationDirector JSON and rebuild every stage and actor animation.',
|
|
icon: 'playlist_add',
|
|
click: importConjoinedAnimation,
|
|
});
|
|
|
|
actorPreferencesAction = new Action('edit_actor_preferences', {
|
|
name: 'Actor Preferences',
|
|
description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.',
|
|
icon: 'psychology',
|
|
click: editActorPreferences,
|
|
});
|
|
|
|
boneTextureOverridesAction = new Action('edit_bone_texture_overrides', {
|
|
name: 'Bone Texture Overrides',
|
|
description: 'Set recursive texture overrides for folders in any model project.',
|
|
icon: 'texture',
|
|
click: editBoneTextureOverrides,
|
|
});
|
|
|
|
exportGeckoModelAction = new Action('animationdirector_export_gecko_model', {
|
|
name: 'Export GeckoLib Model',
|
|
description: 'Export GeckoLib geometry, including m/f/mf variants with separate excluded-bone selections.',
|
|
icon: 'file_upload',
|
|
click: exportGeckoModel,
|
|
});
|
|
if (typeof ModelProject !== 'undefined' && !ModelProject.properties[MODEL_VARIANTS_PROPERTY]) {
|
|
modelVariantsProjectProperty = new Property(ModelProject, 'string', MODEL_VARIANTS_PROPERTY, { default: '{}' });
|
|
}
|
|
if (typeof ModelProject !== 'undefined' && !ModelProject.properties[JEM_VARIANTS_PROPERTY]) {
|
|
jemVariantsProjectProperty = new Property(ModelProject, 'string', JEM_VARIANTS_PROPERTY, { default: '{}' });
|
|
}
|
|
createJemModelAction = new Action('animationdirector_create_jem_model', {
|
|
condition: () => { updateJemExportAction(); return !Blockbench.isWeb; },
|
|
name: 'Create JEM Model', icon: 'icon-format_optifine',
|
|
description: 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.',
|
|
click: showCreateJemModel,
|
|
});
|
|
jemExportActionListener = updateJemExportAction;
|
|
Blockbench.on('select_project unselect_project load_project new_project', jemExportActionListener);
|
|
updateJemExportAction();
|
|
|
|
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: '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: 'assignment_add',
|
|
click: importAnimationDefinition,
|
|
});
|
|
automaticImportAction = new Action('animationdirector_automatic_import', {
|
|
condition: () => !Blockbench.isWeb,
|
|
name: 'Import Whole Animation', icon: 'add_box',
|
|
description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.',
|
|
click: automaticallyImportAnimation,
|
|
});
|
|
packFoldersAction = new Action('animationdirector_pack_folders', {
|
|
condition: () => !Blockbench.isWeb,
|
|
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, createJemModelAction],
|
|
});
|
|
|
|
MenuBar.menus.tools.addAction({
|
|
id: 'animationdirector_anims',
|
|
name: 'AnimationDirector Anims',
|
|
icon: 'movie',
|
|
children: [
|
|
automaticImportAction,
|
|
importAction,
|
|
importConjoinedAnimationAction,
|
|
importAnimationDefinitionAction,
|
|
packFoldersAction,
|
|
'_',
|
|
actorPreferencesAction,
|
|
replaceActorAction,
|
|
'_',
|
|
blockRequirementAction,
|
|
propAction,
|
|
soundLibraryAction,
|
|
particleLibraryAction,
|
|
particlePreviewToggle,
|
|
'_',
|
|
createAnimationDefinitionAction,
|
|
exportConjoinedAction,
|
|
],
|
|
});
|
|
|
|
actorTexturePreviewProjectListener = scheduleActorTexturePreviewApply;
|
|
for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
|
|
Blockbench.on(eventName, actorTexturePreviewProjectListener);
|
|
}
|
|
scheduleActorTexturePreviewApply();
|
|
|
|
MenuBar.update();
|
|
},
|
|
onunload() {
|
|
removeStageAutoplay();
|
|
autoplayStagesAction?.delete(); autoplayStagesAction = undefined;
|
|
for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton);
|
|
loopPoseAction?.delete(); loopPoseAction = undefined;
|
|
for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton);
|
|
previousStagePoseAction?.delete(); previousStagePoseAction = undefined;
|
|
Blockbench.removeListener('timeline_play', figuraSoundPlayListener);
|
|
removeFiguraSoundCompat();
|
|
for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener);
|
|
if (typeof document !== 'undefined') document.getElementById('animationdirector_sound_library_button')?.remove();
|
|
soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete();
|
|
if (typeof document !== 'undefined') document.getElementById('animationdirector_particle_library_button')?.remove();
|
|
particleLibraryAction?.delete(); particleLibraryDialog?.delete(); particleLibrary = undefined;
|
|
Blockbench.removeListener('close_project', clearParticlePreviews);
|
|
clearParticlePreviews(); particlePreviewProperty?.delete(); particlePreviewToggle?.delete();
|
|
clearSoundAudio(); soundLibrary = undefined;
|
|
clearPropMeshes(); propSources.clear();
|
|
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();
|
|
if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener);
|
|
propDialog = undefined; propListener = undefined;
|
|
clearScheduledActorTexturePreviewApplies();
|
|
if (actorTexturePreviewProjectListener) {
|
|
for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
|
|
Blockbench.removeListener(eventName, actorTexturePreviewProjectListener);
|
|
}
|
|
actorTexturePreviewProjectListener = null;
|
|
}
|
|
MenuBar.menus.tools.removeAction('animationdirector_model');
|
|
MenuBar.menus.tools.removeAction('animationdirector_anims');
|
|
importAction.delete();
|
|
importConjoinedAnimationAction.delete();
|
|
actorPreferencesAction.delete();
|
|
boneTextureOverridesAction.delete();
|
|
exportGeckoModelAction.delete();
|
|
createJemModelAction?.delete();
|
|
for (const event of ['select_project', 'unselect_project', 'load_project', 'new_project']) {
|
|
if (jemExportActionListener) Blockbench.removeListener(event, jemExportActionListener);
|
|
}
|
|
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();
|
|
modelVariantsProjectProperty?.delete();
|
|
jemVariantsProjectProperty?.delete();
|
|
if (blockRequirementsProjectProperty?.delete) blockRequirementsProjectProperty.delete();
|
|
},
|
|
});
|
|
})();
|