diff --git a/AnimationDirector.js b/AnimationDirector.js index 2439c01..e9a33c6 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -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//afw_animdefs/. Actor metadata is stored in this Blockbench project for later exports.', + 'Save it under data//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 .p animation group.', + name: 'Create Animation Definition', + description: 'Generate an AFW animdef from one .p 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(); }, }); })(); diff --git a/Logo.png b/logo.png similarity index 100% rename from Logo.png rename to logo.png diff --git a/test/animdef-generator.test.js b/test/animdef-generator.test.js new file mode 100644 index 0000000..e10f549 --- /dev/null +++ b/test/animdef-generator.test.js @@ -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); +});