animdef generator overhaul

This commit is contained in:
2026-09-28 18:16:41 +02:00
parent 63f79d9002
commit 9d41f962eb
3 changed files with 556 additions and 99 deletions
+356 -99
View File
@@ -5,9 +5,20 @@
const BONE_TEXTURE_OVERRIDES_PROPERTY = 'multiactor_bone_texture_overrides';
const ACTOR_ANIMDEF_METADATA_PROPERTY = 'multiactor_actor_animdef_metadata';
const ANIMDEF_ANIMATORS_PROPERTY = 'multiactor_animdef_animators';
const ANIMDEF_DEFINITIONS_PROPERTY = 'animationdirector_animdef_definitions';
const SYSTEM_TAG_OPTIONS = {
'': 'None (normal animation)',
manualpeak: 'Manual peak',
defeated: 'Defeated',
stuck: 'Stuck',
tease: 'Tease',
birth: 'Birth',
fillbottle: 'Fill bottle',
other: 'Other',
};
let importAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction;
let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty;
let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty;
let actorTexturePreviewProjectListener;
const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path'];
let actorTexturePreviewTimeouts = [];
@@ -40,6 +51,12 @@
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 {};
@@ -133,6 +150,32 @@
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 = {};
@@ -142,8 +185,10 @@
const entityVariant = String(source.entity_variant || '').trim();
const activity = String(source.activity || '').trim().toLowerCase();
const injector = String(source.injector || '').trim().toUpperCase();
const propLeft = String(source.prop_left || '').trim();
const propRight = String(source.prop_right || '').trim();
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;
@@ -152,8 +197,11 @@
if (activity === 'active' || activity === 'passive') out.activity = activity;
if (injector === 'V' || injector === 'A' || injector === 'M') out.injector = injector;
if (source.receiver === true) out.receiver = true;
if (propLeft) out.prop_left = propLeft;
if (propRight) out.prop_right = propRight;
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;
}
@@ -278,12 +326,16 @@
return inferred ? { ...clean, entity_types: [inferred] } : clean;
}
function addActorAnimdefFormFields(form, prefix, actorIndex, actorLabel, metadata, defaultActivity, condition) {
function addActorAnimdefFormFields(form, prefix, actorIndex, actorLabel, metadata, defaultActivity, condition, actorLabels) {
const base = `${prefix}_${actorIndex}`;
const fieldCondition = condition || undefined;
const targetOptions = {};
for (const label of actorLabels) {
if (label !== actorLabel) targetOptions[label] = label;
}
form[`${base}_heading`] = {
type: 'info',
text: `actor${actorIndex}: ${actorLabel}`,
text: `#### Actor ${actorIndex} (${actorLabel})`,
condition: fieldCondition,
};
form[`${base}_entity_types`] = {
@@ -329,25 +381,41 @@
value: metadata.injector || '',
condition: fieldCondition,
};
form[`${base}_injects_into`] = {
label: 'Inject into',
type: 'multi_select',
options: targetOptions,
value: metadata.injects_into || [],
description: 'Optional. Select one or more other actors. No selection targets all eligible receivers.',
condition: fieldCondition,
};
form[`${base}_receiver`] = {
label: 'Explicit receiver',
label: 'Receiver',
type: 'checkbox',
value: metadata.receiver === true,
description: 'An actor cannot be both an injector and an explicit receiver.',
description: 'An actor can both inject and receive. If any receiver is explicit, only explicit receivers are targeted.',
condition: fieldCondition,
};
form[`${base}_prop_left`] = {
label: 'Default left prop',
form[`${base}_held_items`] = {
label: 'Required held items',
type: 'text',
value: metadata.prop_left || '',
placeholder: 'minecraft:carrot',
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}_prop_right`] = {
label: 'Default right prop',
type: 'text',
value: metadata.prop_right || '',
placeholder: 'minecraft:carrot',
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,
};
}
@@ -362,8 +430,12 @@
activity: result?.[`${base}_activity`],
injector: result?.[`${base}_injector`],
receiver: !!result?.[`${base}_receiver`],
prop_left: result?.[`${base}_prop_left`],
prop_right: result?.[`${base}_prop_right`],
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`],
},
});
}
@@ -376,16 +448,14 @@
if (metadata.entity_variant && !/^[a-z0-9_]+$/.test(metadata.entity_variant)) {
throw new Error(`Actor "${actorLabel}" has invalid entity variant "${metadata.entity_variant}".`);
}
for (const [key, value] of [
['left prop', metadata.prop_left],
['right prop', metadata.prop_right],
]) {
if (value && !RESOURCE_ID_PATTERN.test(value)) {
throw new Error(`Actor "${actorLabel}" has invalid ${key} item ID "${value}". Use namespace:path.`);
}
if (metadata.injects_into?.length && !metadata.injector) {
throw new Error(`Actor "${actorLabel}" needs an injector role to use injects_into.`);
}
if (metadata.injector && metadata.receiver) {
throw new Error(`Actor "${actorLabel}" cannot be both injector ${metadata.injector} and an explicit receiver.`);
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.`);
}
}
}
@@ -657,7 +727,7 @@
type: 'checkbox',
style: 'toggle_switch',
value: showAnimdefMetadata,
description: 'Show persistent AFW actor constraints used by animation-definition stub exports.',
description: 'Show persistent AFW actor constraints used by animation-definition exports.',
};
form._animdef_info = {
type: 'info',
@@ -674,7 +744,8 @@
label,
metadata,
actorPosition === 0 ? 'active' : 'passive',
(result) => result?.show_animdef_metadata ?? showAnimdefMetadata
(result) => result?.show_animdef_metadata ?? showAnimdefMetadata,
actorIndices.map((actor) => names[String(actor)] || `actor${actor}`)
);
});
@@ -1889,8 +1960,9 @@
if (metadata.activity) actor.activity = metadata.activity;
if (metadata.injector) actor.injector = metadata.injector;
if (metadata.receiver) actor.receiver = true;
if (metadata.prop_left) actor.prop_left = metadata.prop_left;
if (metadata.prop_right) actor.prop_right = metadata.prop_right;
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;
}
@@ -1914,9 +1986,12 @@
`Stage p${stage.stageNumber} duration multiplier`,
(value) => loop && value >= 0.05 && value <= 20
);
const escapable = Object.prototype.hasOwnProperty.call(result || {}, `${base}_escapable`)
? !!result[`${base}_escapable`]
: !isLastStage;
const useStage = parseOptionalNumber(
result?.[`${base}_use_stage`],
`Stage p${stage.stageNumber} playback stage`,
(value) => Number.isInteger(value) && value > 0
);
const escapable = String(result?.[`${base}_escapable`] ?? String(!isLastStage));
const peaked = Object.prototype.hasOwnProperty.call(result || {}, `${base}_peaked`)
? !!result[`${base}_peaked`]
: isLastStage;
@@ -1930,9 +2005,10 @@
: !isLastStage,
};
if (midpointOffset !== null) output.cycle_midpoint_offset_seconds = midpointOffset;
if (useStage !== null && useStage !== stage.stageNumber) output.use_stage = useStage;
if (speed !== null) output.speed = speed;
if (peaked) output.non_peak = true;
output.escapable = escapable;
if (escapable !== 'default') output.escapable = escapable === 'true';
if (stageSeconds !== null) output.stage_seconds = stageSeconds;
if (durationMultiplier !== null) output.stage_duration_multiplier = durationMultiplier;
return output;
@@ -1942,6 +2018,7 @@
const mergedMetadata = { ...getProjectActorAnimdefMetadata(), ...actorMetadata };
setProjectActorAnimdefMetadata(mergedMetadata);
setProjectAnimdefAnimators(definition.animators || []);
saveAnimdef(group.id, definition);
Blockbench.export(
{
type: 'AFW Animation Definition',
@@ -1968,23 +2045,29 @@
title: 'Unresolved Entity Types',
message:
`Could not infer entity types for: ${unresolvedLabels.join(', ')}.\n\n` +
'The stub will omit entity_types for these actors, which makes them wildcard constraints. Manually add the correct entity_types before using this animation definition.',
buttons: ['Cancel', 'Export Stub'],
'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 Stub') onContinue();
if (button === 1 || button === 'Export Animdef') onContinue();
}
);
}
function showAnimationDefinitionReviewDialog(group, analysis) {
const savedMetadata = getProjectActorAnimdefMetadata();
const saved = getSavedAnimdef(group.id);
const savedStages = new Map((Array.isArray(saved.stages) ? saved.stages : []).map((stage) => [stage.stage, stage]));
const savedSystemTags = uniqueTrimmedStrings((Array.isArray(saved.system_tags) ? saved.system_tags : [])
.map((tag) => String(tag).toLowerCase()));
const savedTag = savedSystemTags[0] || '';
const selectedSystemTag = Object.hasOwn(SYSTEM_TAG_OPTIONS, savedTag) ? savedTag : 'other';
const form = {
_info: {
type: 'info',
text:
`Creating ${group.id}.json with ${analysis.actorIndices.length} actor(s) and ${analysis.stages.length} stage(s). ` +
'Save it under data/<namespace>/afw_animdefs/. Actor metadata is stored in this Blockbench project for later exports.',
'Save it under data/<namespace>/afw_animdefs/. Your choices are stored in this Blockbench project for later exports.',
},
};
if (analysis.warnings.length) {
@@ -1993,58 +2076,81 @@
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: '',
placeholder: group.id,
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: getProjectAnimdefAnimators().join(', '),
value: (saved.animators || getProjectAnimdefAnimators()).join(', '),
placeholder: 'Animator One, Animator Two',
description: 'Optional comma-separated individual animation credits. Saved in this Blockbench project.',
};
const isTease = (result) => result?.stub_behavior === 'tease';
const isStuck = (result) => result?.stub_behavior === 'stuck';
form._behavior = { type: 'info', text: 'Specialized NoN behavior' };
form.stub_behavior = {
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: {
standard: 'Standard animation',
tease: 'Tease animation',
stuck: 'Stuck animation',
},
value: 'standard',
description: 'Adds the corresponding system tag and metadata object to the stub.',
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: 2,
value: saved.tease?.near_animation_energy_multiplier ?? 2,
min: 1,
max: 20,
step: 0.1,
condition: isTease,
};
form.stub_stuck_content_tags = {
label: 'Matching content tags',
type: 'text',
value: '',
placeholder: 'stuckfence, restrained',
description: 'Comma-separated exact tags used to match the preceding attack.',
condition: isStuck,
form.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: 2,
value: saved.stuck?.initial_escape_difficulty_multiplier ?? 2,
min: 0.1,
max: 10,
step: 0.05,
@@ -2053,7 +2159,7 @@
form.stub_stuck_final_difficulty = {
label: 'Final escape difficulty',
type: 'number',
value: 0.75,
value: saved.stuck?.final_escape_difficulty_multiplier ?? 0.75,
min: 0.1,
max: 10,
step: 0.05,
@@ -2062,7 +2168,7 @@
form.stub_stuck_ease_delay = {
label: 'Ease delay seconds',
type: 'number',
value: 10,
value: saved.stuck?.ease_delay_seconds ?? 10,
min: 0,
max: 600,
step: 1,
@@ -2071,7 +2177,7 @@
form.stub_stuck_ease_duration = {
label: 'Ease duration seconds',
type: 'number',
value: 30,
value: saved.stuck?.ease_duration_seconds ?? 30,
min: 0,
max: 600,
step: 1,
@@ -2080,7 +2186,7 @@
form.stub_stuck_joined_difficulty = {
label: 'Joined escape difficulty',
type: 'number',
value: 1.5,
value: saved.stuck?.joined_escape_difficulty_multiplier ?? 1.5,
min: 0.1,
max: 10,
step: 0.05,
@@ -2094,11 +2200,72 @@
ignore_damage: 'Apply damage without stopping',
block_damage: 'Prevent damage',
},
value: 'stop_on_damage',
value: saved.stuck?.damage_behavior || 'stop_on_damage',
condition: isStuck,
};
form._actors = { type: 'info', text: 'Actor constraints' };
const showAdvancedMatching = !!(saved.required_union_tags?.length || saved.join_after?.length
|| saved.match_content_on_join || (saved.weight != null && saved.weight !== 1));
form.matching_advanced = {
label: 'Advanced', type: 'checkbox', style: 'toggle_switch', value: showAdvancedMatching,
description: 'Show additional matching and selection rules.',
};
const advancedMatching = (result) => result?.matching_advanced ?? showAdvancedMatching;
form.required_union_tags = {
label: 'Required union tags', type: 'text', value: (saved.required_union_tags || []).join(', '),
placeholder: 'some.tag',
description: 'All tags must exist among the selected actors combined.',
condition: advancedMatching,
};
form.join_after = {
label: 'Join after', type: 'text', value: (saved.join_after || []).join(', '),
placeholder: 'creator:previous_animation',
description: 'Optional comma-separated exact namespaced animation IDs.',
condition: advancedMatching,
};
form.match_content_on_join = {
label: 'Match content on join', type: 'checkbox', value: saved.match_content_on_join === true,
description: 'Successors joining this animation must share at least one content tag.',
condition: advancedMatching,
};
form.weight = {
label: 'Selection weight', type: 'text', value: saved.weight ?? '', placeholder: 'Default (1.0)',
description: 'Positive random-selection weight among otherwise eligible animations.',
condition: advancedMatching,
};
form._placement_separator = '_';
form._placement = { type: 'info', text: '### Playback and placement' };
form.base_speed = {
label: 'Base playback speed', type: 'text', value: saved.speed ?? '', placeholder: 'Default (1.0)',
description: 'Positive speed used unless an individual stage overrides it.',
};
form.water = {
label: 'Water placement', type: 'select', value: saved.water || 'none',
options: { none: 'No water requirement', surface: 'Water surface', underwater: 'Underwater' },
};
const anchorOptions = { '': 'Automatic anchor' };
for (const actorIndex of analysis.actorIndices) {
const label = analysis.actorLabels.get(actorIndex);
anchorOptions[label] = label;
}
form.position_anchor_actor = {
label: 'Position anchor actor', type: 'select', value: saved.position_anchor_actor || '', options: anchorOptions,
description: 'Prefer this actor as the animation placement anchor.',
};
form.camera_torso_tracking = {
label: 'Camera torso tracking', type: 'select', value: saved.camera?.torso_tracking || 'adaptive',
options: { adaptive: 'Adaptive (default)', continuous: 'Continuous' },
description: 'Continuous tracking follows the animated torso during each stage.',
};
form.camera_block_collision = {
label: 'Camera block collision', type: 'select', value: saved.camera?.block_requirement_collision || 'normal',
options: { normal: 'Normal (default)', ignore: 'Ignore required blocks' },
description: 'Ignore collision only with the blocks used by a block requirement.',
};
form._actors_separator = '_';
form._actors = { type: 'info', text: '### Actor constraints' };
analysis.actorIndices.forEach((actorIndex, actorPosition) => {
const label = analysis.actorLabels.get(actorIndex);
const metadata = actorAnimdefMetadataWithInference(actorIndex, savedMetadata[String(actorIndex)]);
@@ -2108,31 +2275,36 @@
actorIndex,
label,
metadata,
actorPosition === 0 ? 'active' : 'passive'
actorPosition === 0 ? 'active' : 'passive',
undefined,
analysis.actorIndices.map((index) => analysis.actorLabels.get(index))
);
});
form._stages = { type: 'info', text: 'Stage metadata' };
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`] ?? stage.loop);
showAdvanced(result) && (result?.[`${base}_loop`] ?? stageLoop);
form[`${base}_heading`] = {
type: 'info',
text: `Stage p${stage.stageNumber}: detected ${stage.loop ? 'looping' : 'non-looping'}, ${stage.cycleSeconds}s`,
text: `#### Stage p${stage.stageNumber}\nDetected ${stage.loop ? 'looping' : 'non-looping'}, ${stage.cycleSeconds}s`,
};
form[`${base}_loop`] = {
label: 'Loop',
type: 'checkbox',
value: stage.loop,
value: stageLoop,
};
form[`${base}_cycle_seconds`] = {
label: 'Cycle seconds',
type: 'number',
value: stage.cycleSeconds,
value: savedStage.cycle_seconds ?? stage.cycleSeconds,
min: 0.001,
step: 0.001,
description: 'Detected from the compiled GeckoLib animation_length.',
@@ -2140,51 +2312,67 @@
form[`${base}_allow_join`] = {
label: 'Allow joins',
type: 'checkbox',
value: !isLastStage,
value: savedStage.allow_join ?? !isLastStage,
};
form[`${base}_escapable`] = {
label: 'Escapable',
type: 'checkbox',
value: !isLastStage,
type: 'select',
options: { default: 'Use top-level default', true: 'Yes', false: 'No' },
value: savedStage.escapable == null
? (isLastStage ? 'false' : 'default') : String(savedStage.escapable),
};
form[`${base}_peaked`] = {
label: 'Peaked stage',
type: 'checkbox',
value: isLastStage,
value: savedStage.non_peak ?? isLastStage,
description: "Marks this as NoN's peaked stage.",
};
form[`${base}_advanced`] = {
label: 'Advanced stage settings',
type: 'checkbox',
style: 'toggle_switch',
value: false,
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: '',
value: savedStage.speed ?? '',
placeholder: 'Default (1.0)',
condition: showAdvanced,
};
form[`${base}_use_stage`] = {
label: 'Play animation from stage',
type: 'select',
options: Object.fromEntries([
['', `Own stage (p${stage.stageNumber})`],
...analysis.stages.map((availableStage) => [
String(availableStage.stageNumber), `Stage p${availableStage.stageNumber}`,
]),
]),
value: savedStage.use_stage == null ? '' : String(savedStage.use_stage),
description: 'Optional stage number whose GeckoLib clip should be played instead.',
condition: showAdvanced,
};
form[`${base}_stage_seconds`] = {
label: 'Stage seconds',
type: 'text',
value: '',
value: savedStage.stage_seconds ?? '',
placeholder: 'Use NoN settings',
condition: showAdvanced,
};
form[`${base}_midpoint_offset`] = {
label: 'Cycle midpoint offset seconds',
type: 'text',
value: '',
value: savedStage.cycle_midpoint_offset_seconds ?? '',
placeholder: 'Default (0.0)',
condition: showAdvancedLoop,
};
form[`${base}_duration_multiplier`] = {
label: 'Stage duration multiplier',
type: 'text',
value: '',
value: savedStage.stage_duration_multiplier ?? '',
placeholder: 'Default (1.0)',
condition: showAdvancedLoop,
};
@@ -2195,7 +2383,7 @@
title: `Create Animation Definition: ${group.id}`,
width: 760,
form,
buttons: ['Cancel', 'Create Stub'],
buttons: ['Cancel', 'Create Animdef'],
cancelIndex: 0,
confirmIndex: 1,
onConfirm: (result) => {
@@ -2214,27 +2402,92 @@
const stages = analysis.stages.map((stage, index) =>
readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1)
);
const actorsByLabel = new Map(actors.map((actor) => [actor.label, actor]));
const explicitReceivers = actors.some((actor) => actor.receiver);
const inferredReceivers = actors.some((actor) => !actor.injector);
for (const actor of actors) {
const targets = typeof actor.injects_into === 'string'
? [actor.injects_into] : actor.injects_into || [];
for (const target of targets) {
const receiver = actorsByLabel.get(target);
if (!receiver || target === actor.label) {
throw new Error(`Actor "${actor.label}" has invalid injection target "${target}".`);
}
const canReceive = explicitReceivers ? receiver.receiver
: inferredReceivers ? !receiver.injector
: receiver.entity_types?.includes('minecraft:player');
if (!canReceive) throw new Error(`Actor "${target}" is not a receiver for "${actor.label}".`);
}
}
const stageNumbers = new Set(stages.map((stage) => stage.stage));
for (const stage of stages) {
if (stage.use_stage && !stageNumbers.has(stage.use_stage)) {
throw new Error(`Stage p${stage.stage} references missing playback stage p${stage.use_stage}.`);
}
}
const displayName = String(result?.display_name || '').trim();
const animators = normalizeAnimatorNames(String(result?.animators || '').split(','));
const definition = {};
if (displayName) definition.display_name = displayName;
if (animators.length) definition.animators = animators;
const behavior = String(result?.stub_behavior || 'standard');
if (behavior === 'tease') {
const selectedTag = String(result?.system_tags || '');
const customTag = String(result?.other_system_tags || '').trim().toLowerCase();
if (selectedTag === 'other' && (!customTag || customTag.includes(','))) {
throw new Error('Enter one custom system tag or choose a different behavior.');
}
const systemTags = selectedTag === 'other' ? [customTag] : selectedTag ? [selectedTag] : [];
const contentTags = uniqueTrimmedStrings(parseCommaSeparatedStrings(result?.content_tags)
.map((tag) => tag.toLowerCase()));
const unionTags = parseCommaSeparatedStrings(result?.required_union_tags);
const joinAfter = parseCommaSeparatedStrings(result?.join_after);
for (const id of joinAfter) {
if (!RESOURCE_ID_PATTERN.test(id)) {
throw new Error(`Invalid join_after animation ID "${id}". Use namespace:path.`);
}
}
if (systemTags.length) definition.system_tags = systemTags;
if (contentTags.length) definition.content_tags = contentTags;
if (unionTags.length) definition.required_union_tags = unionTags;
if (joinAfter.length) definition.join_after = joinAfter;
if (result?.match_content_on_join) {
if (!contentTags.length) throw new Error('Match content on join requires at least one content tag.');
definition.match_content_on_join = true;
}
const weight = parseOptionalNumber(result?.weight, 'Selection weight', (value) => value > 0);
if (weight !== null && weight !== 1) definition.weight = weight;
if (result?.attack_eligible === false) definition.attack_eligible = false;
const baseSpeed = parseOptionalNumber(result?.base_speed, 'Base playback speed', (value) => value > 0);
if (baseSpeed !== null && baseSpeed !== 1) definition.speed = baseSpeed;
const water = String(result?.water || 'none');
if (water !== 'none') definition.water = water;
const anchor = String(result?.position_anchor_actor || '');
if (anchor) definition.position_anchor_actor = anchor;
const camera = {};
if (result?.camera_torso_tracking === 'continuous') camera.torso_tracking = 'continuous';
if (result?.camera_block_collision === 'ignore') camera.block_requirement_collision = 'ignore';
if (Object.keys(camera).length) definition.camera = camera;
const liquidGain = parseOptionalNumber(result?.liquid_gain_multiplier,
'Liquid gain multiplier', (value) => value >= 0);
if (liquidGain !== null && liquidGain !== 1) definition.liquid_gain_multiplier = liquidGain;
if (systemTags.includes('manualpeak')) {
const priority = parseOptionalNumber(result?.manual_peak_priority,
'Manual peak priority', Number.isInteger);
if (priority !== null && priority !== 0) definition.manual_peak = { priority };
}
if (systemTags.includes('tease')) {
const multiplier = parseOptionalNumber(
result?.stub_tease_multiplier,
'Nearby energy multiplier',
(value) => value >= 1 && value <= 20
);
if (multiplier === null) throw new Error('Nearby energy multiplier is required.');
definition.system_tags = ['tease'];
definition.tease = { near_animation_energy_multiplier: multiplier };
} else if (behavior === 'stuck') {
const contentTags = parseCommaSeparatedStrings(result?.stub_stuck_content_tags);
}
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)) {
if (!stages.some((stage) => stage.escapable ?? true)) {
throw new Error('A stuck animation needs at least one escapable stage.');
}
const initial = parseOptionalNumber(
@@ -2268,8 +2521,6 @@
if (finalDifficulty > initial) {
throw new Error('Final escape difficulty cannot be harder than the initial difficulty.');
}
definition.system_tags = ['stuck'];
definition.content_tags = contentTags;
definition.stuck = {
initial_escape_difficulty_multiplier: initial,
final_escape_difficulty_multiplier: finalDifficulty,
@@ -2327,7 +2578,7 @@
const defaultId = selectedStage && options[selectedStage.id] ? selectedStage.id : detected.groups[0].id;
const dialog = new Dialog({
id: 'create_afw_animation_definition',
title: 'Create Animation Definition Stub',
title: 'Create Animation Definition',
width: 620,
form: {
_info: {
@@ -3227,8 +3478,8 @@
It lets you import and animate multiple actors in one project without Blockbench auto-renaming bones when different models use the same bone names (like head, body, etc.). It does this by organizing each actor with an actorN_ prefix, so everything stays consistent.
You can import or export complete AFW animation IDs as one file containing all stages and actors, or generate a reviewed AFW animation-definition stub from the current project. The deprecated split-file import and export tools remain available for older projects.`,
version: '1.0.0',
You can import or export complete AFW animation IDs as one file containing all stages and actors, or generate a configurable AFW animation definition from the current project. The deprecated split-file import and export tools remain available for older projects.`,
version: '1.1.0',
variant: 'both',
onload() {
if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
@@ -3257,6 +3508,11 @@ You can import or export complete AFW animation IDs as one file containing all s
default: '',
});
}
if (!ModelProject.properties[ANIMDEF_DEFINITIONS_PROPERTY]) {
animdefDefinitionsProjectProperty = new Property(ModelProject, 'string', ANIMDEF_DEFINITIONS_PROPERTY, {
default: '{}',
});
}
}
importAction = new Action('import_actor_gecko_json', {
@@ -3327,8 +3583,8 @@ You can import or export complete AFW animation IDs as one file containing all s
});
createAnimationDefinitionAction = new Action('create_afw_animation_definition_stub', {
name: 'Create Animation Definition Stub',
description: 'Create a reviewed AFW animdef stub from one <animationId>.p<number> animation group.',
name: 'Create Animation Definition',
description: 'Generate an AFW animdef from one <animationId>.p<number> animation group.',
icon: 'post_add',
click: showCreateAnimationDefinitionDialog,
});
@@ -3392,6 +3648,7 @@ You can import or export complete AFW animation IDs as one file containing all s
if (boneTextureOverridesProjectProperty?.delete) boneTextureOverridesProjectProperty.delete();
if (actorAnimdefMetadataProjectProperty?.delete) actorAnimdefMetadataProjectProperty.delete();
if (animdefAnimatorsProjectProperty?.delete) animdefAnimatorsProjectProperty.delete();
if (animdefDefinitionsProjectProperty?.delete) animdefDefinitionsProjectProperty.delete();
},
});
})();