animdef generator overhaul
This commit is contained in:
+356
-99
@@ -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();
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user