This commit is contained in:
2026-10-01 01:35:33 +02:00
parent e7bb16df64
commit a249f1bbdd
2 changed files with 105 additions and 16 deletions
+58 -13
View File
@@ -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. <sup>1</sup>
- 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. <sup>1</sup>
- 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. <sup>2</sup>
@@ -6679,11 +6723,11 @@ Build, preview, and export multi-actor animations with tools for actor models, a
**Addon Notes**
<sup>1</sup> Asset Browser - optional; its cached Minecraft assets can be used for textures and item previews.
<sup>1</sup> Asset Browser - optional; its cached Minecraft assets can be used for textures, item previews, and the particle library.
<sup>2</sup> 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 ? '' : '{}');
+47 -3
View File
@@ -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', () => {