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
+355 -98
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.`);
}
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.`);
}
if (metadata.injector && metadata.receiver) {
throw new Error(`Actor "${actorLabel}" cannot be both injector ${metadata.injector} and an explicit receiver.`);
}
}
@@ -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();
},
});
})();
View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+200
View File
@@ -0,0 +1,200 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function createHarness() {
const file = path.join(__dirname, '..', 'AnimationDirector.js');
const source = fs.readFileSync(file, 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.adTest = { showAnimationDefinitionReviewDialog };\n Plugin.register(PLUGIN_ID, {'
);
const dialogs = [];
const exports = [];
const errors = [];
const project = { multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
const context = {
Project: project,
Plugin: { register() {} },
Dialog: class {
constructor(options) { this.options = options; dialogs.push(options); }
show() {}
},
Blockbench: {
export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
showMessageBox(options) { errors.push(options.message); },
showQuickMessage() {},
},
console,
};
vm.runInNewContext(source, context, { filename: file });
const group = { id: 'test_animation' };
const analysis = {
actorIndices: [1, 2],
actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
stages: [
{ stageNumber: 1, name: 'test_animation.p1', loop: true, cycleSeconds: 1 },
{ stageNumber: 2, name: 'test_animation.p2', loop: false, cycleSeconds: 2 },
],
warnings: [],
};
function open() {
context.adTest.showAnimationDefinitionReviewDialog(group, analysis);
return dialogs.at(-1);
}
return { open, exports, errors, project };
}
function defaults(form) {
return Object.fromEntries(Object.entries(form)
.filter(([, field]) => field && typeof field === 'object' && 'value' in field)
.map(([key, field]) => [key, field.value]));
}
test('generates extended animdef fields and restores them on reopen', () => {
const harness = createHarness();
const dialog = harness.open();
const result = defaults(dialog.form);
result.stub_actor_1_injector = 'V';
result.stub_actor_1_receiver = true;
result.stub_actor_1_injects_into = ['wolf'];
result.stub_actor_1_held_items = 'minecraft:carrot, #minecraft:swords';
result.stub_actor_1_held_hand = 'either';
result.stub_actor_1_held_prop_slot = 'right';
result.stub_actor_2_receiver = true;
result.system_tags = 'manualpeak';
result.content_tags = 'test, tag';
result.required_union_tags = 'example.tag';
result.join_after = 'creator:previous';
result.match_content_on_join = true;
result.weight = '2.5';
result.attack_eligible = false;
result.manual_peak_priority = '10';
result.liquid_gain_multiplier = '0.5';
result.base_speed = '1.25';
result.water = 'surface';
result.position_anchor_actor = 'wolf';
result.camera_torso_tracking = 'continuous';
result.camera_block_collision = 'ignore';
result.stub_stage_2_use_stage = '1';
assert.equal(dialog.onConfirm(result), true);
assert.equal(harness.errors.length, 0);
assert.equal(harness.exports.length, 1);
const definition = harness.exports[0];
assert.equal(definition.weight, 2.5);
assert.equal(definition.attack_eligible, false);
assert.deepEqual(Array.from(definition.system_tags), ['manualpeak']);
assert.deepEqual(Array.from(definition.content_tags), ['test', 'tag']);
assert.deepEqual(Array.from(definition.join_after), ['creator:previous']);
assert.equal(definition.manual_peak.priority, 10);
assert.equal(definition.actors[0].injects_into, 'wolf');
assert.equal(definition.actors[0].receiver, true);
assert.equal(definition.actors[0].held_item.prop_slot, 'right');
assert.equal('prop_floor' in definition.actors[1], false);
assert.equal(definition.stages[1].use_stage, 1);
assert.equal(definition.camera.torso_tracking, 'continuous');
assert.equal(definition.water, 'surface');
assert.equal(definition.position_anchor_actor, 'wolf');
assert.equal('stage_seconds' in definition, false);
assert.equal('escapable' in definition, false);
const reopened = harness.open();
assert.equal(reopened.form.weight.value, 2.5);
assert.equal(reopened.form.matching_advanced.value, true);
assert.equal(reopened.form.system_tags.value, 'manualpeak');
assert.equal(reopened.form.stub_actor_1_injector.value, 'V');
assert.deepEqual(Array.from(reopened.form.stub_actor_1_injects_into.value), ['wolf']);
assert.equal(reopened.form.stub_stage_2_use_stage.value, '1');
});
test('rejects nonexistent injection targets and playback stages', () => {
const harness = createHarness();
const dialog = harness.open();
const result = defaults(dialog.form);
result.stub_actor_1_injector = 'V';
result.stub_actor_1_injects_into = ['missing'];
assert.equal(dialog.onConfirm(result), false);
assert.match(harness.errors.at(-1), /invalid injection target/);
result.stub_actor_1_injects_into = ['wolf'];
result.stub_stage_2_use_stage = '99';
assert.equal(dialog.onConfirm(result), false);
assert.match(harness.errors.at(-1), /missing playback stage/);
assert.equal(harness.exports.length, 0);
});
test('groups advanced matching and offers only other actors as injection targets', () => {
const form = createHarness().open().form;
const keys = Object.keys(form);
assert.equal(form.matching_advanced.value, false);
for (const key of ['required_union_tags', 'join_after', 'match_content_on_join', 'weight']) {
assert.equal(form[key].condition({ matching_advanced: false }), false);
assert.equal(form[key].condition({ matching_advanced: true }), true);
assert.ok(keys.indexOf(key) > keys.indexOf('matching_advanced'));
}
assert.equal(form.stub_actor_1_receiver.label, 'Receiver');
assert.equal(form.stub_actor_1_injects_into.type, 'multi_select');
assert.deepEqual(Object.keys(form.stub_actor_1_injects_into.options), ['wolf']);
assert.deepEqual(Object.keys(form.stub_actor_2_injects_into.options), ['player']);
assert.equal(keys.indexOf('stub_actor_1_injects_into'), keys.indexOf('stub_actor_1_injector') + 1);
assert.equal(keys.some((key) => /_prop_(left|right|floor)$/.test(key)), false);
assert.equal('default_stage_seconds' in form, false);
assert.equal('default_escapable' in form, false);
assert.equal('stub_behavior' in form, false);
assert.equal(form.system_tags.type, 'select');
assert.deepEqual(Object.keys(form.system_tags.options), [
'', 'manualpeak', 'defeated', 'stuck', 'tease', 'birth', 'fillbottle', 'other',
]);
assert.equal(form.stub_stage_2_use_stage.type, 'select');
assert.deepEqual(Object.keys(form.stub_stage_2_use_stage.options), ['1', '2', '']);
});
test('keeps minimal definitions minimal and supports specialized behavior', () => {
const harness = createHarness();
const dialog = harness.open();
const minimal = defaults(dialog.form);
assert.equal(dialog.onConfirm(minimal), true);
const first = harness.exports.at(-1);
for (const key of ['weight', 'camera', 'water', 'manual_peak', 'block_requirements']) {
assert.equal(key in first, false, `${key} should be omitted by default`);
}
const teaseDialog = harness.open();
const tease = defaults(teaseDialog.form);
tease.system_tags = 'tease';
tease.stub_tease_multiplier = 3;
assert.equal(teaseDialog.onConfirm(tease), true);
const second = harness.exports.at(-1);
assert.deepEqual(Array.from(second.system_tags), ['tease']);
assert.equal(second.tease.near_animation_energy_multiplier, 3);
const stuckDialog = harness.open();
const stuck = defaults(stuckDialog.form);
stuck.system_tags = 'stuck';
stuck.content_tags = 'stuckfence';
assert.equal(stuckDialog.onConfirm(stuck), true);
const third = harness.exports.at(-1);
assert.deepEqual(Array.from(third.system_tags), ['stuck']);
assert.deepEqual(Array.from(third.content_tags), ['stuckfence']);
assert.equal(third.stuck.damage_behavior, 'stop_on_damage');
});
test('exports one custom behavior through Other', () => {
const harness = createHarness();
const dialog = harness.open();
const result = defaults(dialog.form);
result.system_tags = 'other';
result.other_system_tags = 'custom_tag';
assert.equal(dialog.onConfirm(result), true);
const definition = harness.exports.at(-1);
assert.deepEqual(Array.from(definition.system_tags), ['custom_tag']);
assert.equal('tease' in definition, false);
assert.equal('stuck' in definition, false);
const reopened = harness.open();
assert.equal(reopened.form.system_tags.value, 'other');
assert.equal(reopened.form.other_system_tags.value, 'custom_tag');
assert.equal(reopened.form.other_system_tags.condition({ system_tags: 'other' }), true);
assert.equal(reopened.form.other_system_tags.condition({ system_tags: '' }), false);
});