diff --git a/AnimationDirector.js b/AnimationDirector.js index ad595f6..bcf8996 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -149,6 +149,37 @@ setProjectStringMap(ACTOR_NAMES_PROPERTY, names); } + function remapActorReferences(previousNames, nextNames, indices, metadata) { + const labels = new Map(indices.map((index) => [previousNames[index] || `actor${index}`, + nextNames[index] || `actor${index}`])); + const nextLabels = [...labels.values()]; + if (new Set(nextLabels).size !== nextLabels.length) throw new Error('Actor export names must be unique.'); + const rename = (label) => labels.get(label) ?? label; + const targets = (value) => Array.isArray(value) ? value.map(rename) : typeof value === 'string' ? rename(value) : value; + for (const entry of Object.values(metadata)) { + if (entry.injects_into) entry.injects_into = targets(entry.injects_into); + } + const definitions = JSON.parse(Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}'); + for (const definition of Object.values(definitions)) { + for (const actor of definition.actors || []) { + actor.label = rename(actor.label); + if (actor.injects_into) actor.injects_into = targets(actor.injects_into); + } + if (definition.position_anchor_actor) definition.position_anchor_actor = rename(definition.position_anchor_actor); + for (const stage of definition.stages || []) { + if (stage.props && typeof stage.props === 'object') { + stage.props = Object.fromEntries(Object.entries(stage.props).map(([label, value]) => [rename(label), value])); + } + } + } + const previews = Object.fromEntries(Object.values(readPropPreviews()).map((entry) => { + const updated = { ...entry, actor: rename(entry.actor) }; + return [propEntryKey(updated.animation, updated.actor, updated.slot, updated.stage), updated]; + })); + Project[ANIMDEF_DEFINITIONS_PROPERTY] = JSON.stringify(definitions); + Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(previews); + } + function getProjectActorTextures() { return getProjectStringMap(ACTOR_TEXTURES_PROPERTY); } @@ -392,6 +423,9 @@ for (const label of actorLabels) { if (label !== actorLabel) targetOptions[label] = label; } + for (const label of metadata.injects_into || []) { + if (!Object.hasOwn(targetOptions, label)) targetOptions[label] = `${label} (unavailable - remove or replace)`; + } form[`${base}_heading`] = { type: 'info', text: `#### Actor ${actorIndex} (${actorLabel})`, @@ -1732,12 +1766,14 @@ const aspects = { elements: Cube.all.slice(), uv_mode: true, actor_preferences_edit: true }; Undo.initEdit(aspects); editing = true; + remapActorReferences(names, outNames, actorIndices, outAnimdefMetadata); setProjectActorNames(outNames); setProjectActorTextures(outTextures); setProjectActorAnimdefMetadata(outAnimdefMetadata); setProjectBoneTextureOverrides(outBoneTextureOverrides); applyAllActorTexturePreviews(true); Undo.finishEdit('Edit Actor Preferences', { ...aspects, elements: Cube.all.slice() }); + void refreshPropPreviews(true); return true; } catch (e) { if (editing) Undo.cancelEdit(); @@ -3371,9 +3407,9 @@ const cycleSeconds = parseRequiredPositiveNumber(result?.[`${base}_cycle_seconds`], `Stage p${stage.stageNumber} cycle seconds`); const speed = parseOptionalNumber(result?.[`${base}_speed`], `Stage p${stage.stageNumber} speed`, (value) => value > 0); const midpointOffset = parseOptionalNumber( - result?.[`${base}_midpoint_offset`], + loop ? result?.[`${base}_midpoint_offset`] : '', `Stage p${stage.stageNumber} midpoint offset`, - () => loop + () => true ); const stageSeconds = parseOptionalNumber( result?.[`${base}_stage_seconds`], @@ -3381,12 +3417,12 @@ (value) => Number.isInteger(value) && (value === -1 || (value >= 1 && value <= 300)) ); const durationMultiplier = parseOptionalNumber( - result?.[`${base}_duration_multiplier`], + loop ? result?.[`${base}_duration_multiplier`] : '', `Stage p${stage.stageNumber} duration multiplier`, - (value) => loop && value >= 0.05 && value <= 20 + (value) => value >= 0.05 && value <= 20 ); const useStage = parseOptionalNumber( - result?.[`${base}_use_stage`], + result?.[`${base}_use_stage`] === 'own' ? '' : result?.[`${base}_use_stage`], `Stage p${stage.stageNumber} playback stage`, (value) => Number.isInteger(value) && value > 0 ); @@ -3403,13 +3439,13 @@ ? !!result[`${base}_allow_join`] : !isLastStage, }; - if (midpointOffset !== null) output.cycle_midpoint_offset_seconds = midpointOffset; + if (loop && midpointOffset !== null) output.cycle_midpoint_offset_seconds = midpointOffset; if (useStage !== null && useStage !== stage.stageNumber) output.use_stage = useStage; if (speed !== null) output.speed = speed; if (peaked) output.non_peak = true; if (escapable !== 'default') output.escapable = escapable === 'true'; if (stageSeconds !== null) output.stage_seconds = stageSeconds; - if (durationMultiplier !== null) output.stage_duration_multiplier = durationMultiplier; + if (loop && durationMultiplier !== null) output.stage_duration_multiplier = durationMultiplier; return output; } @@ -3829,6 +3865,9 @@ const label = analysis.actorLabels.get(actorIndex); anchorOptions[label] = label; } + if (saved.position_anchor_actor && !Object.hasOwn(anchorOptions, saved.position_anchor_actor)) { + anchorOptions[saved.position_anchor_actor] = `${saved.position_anchor_actor} (unavailable - replace)`; + } form.position_anchor_actor = { label: 'Position anchor actor', type: 'select', value: saved.position_anchor_actor || '', options: anchorOptions, description: 'Prefer this actor as the animation placement anchor.', @@ -3929,15 +3968,18 @@ label: 'Play animation from stage', type: 'select', options: Object.fromEntries([ - ['', `Own stage (p${stage.stageNumber})`], + ['own', `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), + value: savedStage.use_stage == null ? 'own' : String(savedStage.use_stage), description: 'Optional stage number whose GeckoLib clip should be played instead.', condition: showAdvanced, }; + if (savedStage.use_stage != null && !Object.hasOwn(form[`${base}_use_stage`].options, String(savedStage.use_stage))) { + form[`${base}_use_stage`].options[String(savedStage.use_stage)] = `Stage p${savedStage.use_stage} (unavailable - replace)`; + } form[`${base}_stage_seconds`] = { label: 'Stage seconds', type: 'text', @@ -4041,6 +4083,7 @@ const water = String(result?.water || 'none'); if (water !== 'none') definition.water = water; const anchor = String(result?.position_anchor_actor || ''); + if (anchor && !actorsByLabel.has(anchor)) throw new Error(`Position anchor "${anchor}" is unavailable. Choose another actor or Automatic anchor.`); if (anchor) definition.position_anchor_actor = anchor; const camera = unmanagedAnimdefFields(saved.camera, new Set(['torso_tracking', 'block_requirement_collision'])); if (result?.camera_torso_tracking === 'continuous') camera.torso_tracking = 'continuous'; @@ -6656,7 +6699,7 @@ Plugin.register(PLUGIN_ID, { title: 'AnimationDirector', author: 'L1Z0', - icon: 'cinematic_blur', + icon: 'diversity_4', description: 'Multi-actor animation in Blockbench without bone-name clashes.', about: `AnimationDirector is a Blockbench plugin for creating multi-actor animations for Needs of Nature. @@ -6671,6 +6714,7 @@ Build, preview, and export multi-actor animations with tools for actor models, a - Edit block requirements with live previews of required blocks and clearance areas. - Preview item props using Minecraft assets or custom textures. 1 - Browse and audition sounds with the NoN Sound Library. +- Browse particle sprites and assign cues and locators with the NoN Particle Library, including approximate timeline previews for supported NoN particles. 1 - Export multi-actor, multi-stage animations and GeckoLib models. - Replace actor models while preserving matching animation tracks. - Transfer added geometry into vanilla CEM presets to create JEM models, with optional energy-dependent visibility. 2 @@ -6679,11 +6723,11 @@ Build, preview, and export multi-actor animations with tools for actor models, a **Addon Notes** -1 Asset Browser - optional; its cached Minecraft assets can be used for textures and item previews. +1 Asset Browser - optional; its cached Minecraft assets can be used for textures, item previews, and the particle library. 2 CEM Template Loader - required for JEM creation. -Features without a reference need no additional addon. Whole-animation import and local asset tools require desktop Blockbench. The sound library uses locally available NoN/pack sound files.`, +Features without a reference need no additional addon. Whole-animation import and local asset tools require desktop Blockbench. The sound library uses locally available NoN/pack sound files; NoN particle previews require their texture assets from a NoN JAR or extracted assets folder.`, version: '2.0', min_version: '5.2.1', variant: 'both', @@ -6821,7 +6865,8 @@ Features without a reference need no additional addon. Whole-animation import an if (aspects.actor_texture_edit) save[ACTOR_TEXTURES_PROPERTY] = Project[ACTOR_TEXTURES_PROPERTY] || '{}'; if (aspects.actor_preferences_edit) { for (const property of [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, ACTOR_ANIMDEF_METADATA_PROPERTY, - BONE_TEXTURE_OVERRIDES_PROPERTY, ACTOR_UV_BASIS_PROPERTY]) save[property] = Project[property] || '{}'; + BONE_TEXTURE_OVERRIDES_PROPERTY, ACTOR_UV_BASIS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, + PROP_PREVIEWS_PROPERTY]) save[property] = Project[property] || '{}'; } if (aspects.automatic_animation_import) { for (const property of IMPORT_METADATA) save[property] = Project[property] ?? (property === ANIMDEF_ANIMATORS_PROPERTY ? '' : '{}'); diff --git a/test/animdef-generator.test.js b/test/animdef-generator.test.js index 976c955..69a7574 100644 --- a/test/animdef-generator.test.js +++ b/test/animdef-generator.test.js @@ -8,7 +8,7 @@ function createHarness() { const file = path.join(__dirname, '..', 'AnimationDirector.js'); const source = fs.readFileSync(file, 'utf8').replace( ' Plugin.register(PLUGIN_ID, {', - ' globalThis.adTest = { showAnimationDefinitionReviewDialog, createInjectorGenderDefaultHandler };\n Plugin.register(PLUGIN_ID, {' + ' globalThis.adTest = { showAnimationDefinitionReviewDialog, createInjectorGenderDefaultHandler, remapActorReferences, readStageDefinitionFromForm };\n Plugin.register(PLUGIN_ID, {' ); const dialogs = []; const exports = []; @@ -45,7 +45,7 @@ function createHarness() { context.adTest.showAnimationDefinitionReviewDialog(group, analysis); return dialogs.at(-1); } - return { open, exports, errors, project, analysis, createInjectorGenderDefaultHandler: context.adTest.createInjectorGenderDefaultHandler, + return { open, exports, errors, project, analysis, helpers: context.adTest, createInjectorGenderDefaultHandler: context.adTest.createInjectorGenderDefaultHandler, setFormResult(result) { formResult = result; } }; } @@ -55,6 +55,50 @@ function defaults(form) { .map(([key, field]) => [key, field.value])); } +test('actor renames remap all definitions, metadata and prop previews simultaneously', () => { + const h = createHarness(); + const metadata = { 1: { injects_into: ['wolf'] } }; + const definition = { actors: [{ label: 'player', injects_into: 'wolf', prop_right: 'minecraft:stick' }, + { label: 'wolf' }], position_anchor_actor: 'wolf', stages: [{ stage: 1, props: { wolf: null, player: { prop_left: null } } }] }; + h.project.animationdirector_animdef_definitions = JSON.stringify({ first: definition, second: definition }); + h.project.animationdirector_prop_previews = JSON.stringify({ old: { animation: 'first', actor: 'wolf', slot: 'right', stage: 1 } }); + h.helpers.remapActorReferences({ 1: 'player', 2: 'wolf' }, { 1: 'wolf', 2: 'player' }, [1, 2], metadata); + assert.deepEqual(metadata[1].injects_into, ['player']); + for (const saved of Object.values(JSON.parse(h.project.animationdirector_animdef_definitions))) { + assert.equal(saved.actors[0].label, 'wolf'); + assert.equal(saved.actors[0].injects_into, 'player'); + assert.equal(saved.actors[0].prop_right, 'minecraft:stick'); + assert.equal(saved.position_anchor_actor, 'player'); + assert.equal(saved.stages[0].props.player, null); + assert.deepEqual(saved.stages[0].props.wolf, { prop_left: null }); + } + const previews = JSON.parse(h.project.animationdirector_prop_previews); + assert.equal(previews[JSON.stringify(['first', 'player', 'right', 1])].actor, 'player'); +}); + +test('own stage remains explicit and hidden loop controls do not block non-looping stages', () => { + const h = createHarness(), dialog = h.open(), result = defaults(dialog.form); + assert.equal(result.stub_stage_2_use_stage, 'own'); + result.stub_stage_2_midpoint_offset = 'not applicable'; + result.stub_stage_2_duration_multiplier = 'not applicable'; + const stage = h.helpers.readStageDefinitionFromForm(result, h.analysis.stages[1], true); + assert.equal(stage.use_stage, undefined); + assert.equal(stage.cycle_midpoint_offset_seconds, undefined); + assert.equal(stage.stage_duration_multiplier, undefined); +}); + +test('obsolete injection targets and position anchors remain visible for correction', () => { + const h = createHarness(); + h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: { + actors: [{ label: 'player', entity_types: ['minecraft:player'], injector: 'V', injects_into: 'removed' }], + position_anchor_actor: 'removed', stages: [{ stage: 2, use_stage: 99 }], + } }); + const dialog = h.open(); + assert.match(dialog.form.stub_actor_1_injects_into.options.removed, /unavailable/); + assert.match(dialog.form.position_anchor_actor.options.removed, /unavailable/); + assert.match(dialog.form.stub_stage_2_use_stage.options['99'], /unavailable/); +}); + test('selecting an injector adds an editable male tag without enforcing it on export', () => { for (const role of ['V', 'A', 'M']) { const h = createHarness(), dialog = h.open(), result = defaults(dialog.form), updates = []; @@ -265,7 +309,7 @@ test('groups advanced matching and offers only other actors as injection targets '', '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', '']); + assert.deepEqual(Object.keys(form.stub_stage_2_use_stage.options), ['1', '2', 'own']); }); test('keeps minimal definitions minimal and supports specialized behavior', () => {