From a249f1bbddf49ef32a6fbe7f7d14b7d97a65fba7 Mon Sep 17 00:00:00 2001
From: L1Z0 <2grif484d@mozmail.com>
Date: Thu, 1 Oct 2026 01:35:33 +0200
Subject: [PATCH] fixes
---
AnimationDirector.js | 71 +++++++++++++++++++++++++++-------
test/animdef-generator.test.js | 50 ++++++++++++++++++++++--
2 files changed, 105 insertions(+), 16 deletions(-)
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', () => {