tweaks and improvements

This commit is contained in:
2026-09-29 01:58:15 +02:00
parent 61df6bb0d3
commit 53a5c47549
4 changed files with 166 additions and 31 deletions
+62 -29
View File
@@ -28,7 +28,7 @@
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
const SOUND_SELECTION_BLACKLIST = new Set(['needsofnature:a', 'needsofnature:funkytown']);
const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview';
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection'];
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection', 'undo', 'redo'];
let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition;
let soundLibrary, soundLibraryGeneration = 0;
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
@@ -2826,11 +2826,19 @@
return output;
}
function exportAnimationDefinitionStub(group, definition, actorMetadata) {
function saveAnimationDefinitionFields(group, definition, actorMetadata) {
const undo = typeof Undo !== 'undefined';
if (undo) Undo.initEdit({ animdef_edit: true, actor_preferences_edit: true });
const mergedMetadata = { ...getProjectActorAnimdefMetadata(), ...actorMetadata };
setProjectActorAnimdefMetadata(mergedMetadata);
setProjectAnimdefAnimators(definition.animators || []);
saveAnimdef(group.id, definition);
if (undo) Undo.finishEdit('Save animation definition');
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
}
function exportAnimationDefinitionStub(group, definition, actorMetadata) {
saveAnimationDefinitionFields(group, definition, actorMetadata);
Blockbench.export(
{
type: 'AFW Animation Definition',
@@ -2981,6 +2989,7 @@
const props = importPropPreviews(group.id, definition, sourcePaths, readPropPreviews());
cancelBlockRequirementUpdate();
rebuildBlockRequirementGuides(group.id, entry, definition, true, props.entries);
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
if (props.warnings.length) Blockbench.showQuickMessage(`Item props: ${props.warnings.join(' ')}`, 6000);
void refreshPropPreviews(true);
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id);
@@ -3041,7 +3050,7 @@
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/. Your choices are stored in this Blockbench project for later exports.',
'Save stores your fields in this Blockbench project. Create Animdef also exports JSON for data/<namespace>/afw_animdefs/.',
},
};
if (analysis.warnings.length) {
@@ -3071,7 +3080,7 @@
form.animators = {
label: 'Animators',
type: 'text',
value: (saved.animators || getProjectAnimdefAnimators()).join(', '),
value: (saved.animators || (Object.keys(saved).length ? [] : getProjectAnimdefAnimators())).join(', '),
placeholder: 'Animator One, Animator Two',
description: 'Optional comma-separated individual animation credits. Saved in this Blockbench project.',
};
@@ -3363,15 +3372,7 @@
};
});
const dialog = new Dialog({
id: 'create_afw_animation_definition_review',
title: `Create Animation Definition: ${group.id}`,
width: 760,
form,
buttons: ['Cancel', 'Create Animdef'],
cancelIndex: 0,
confirmIndex: 1,
onConfirm: (result) => {
const submit = (result, exportFile) => {
try {
const actorMetadata = {};
const actors = [];
@@ -3525,9 +3526,14 @@
}
definition.actors = actors;
definition.stages = stages;
confirmUnresolvedActorTypes(unresolvedLabels, () =>
exportAnimationDefinitionStub(group, definition, actorMetadata)
);
if (exportFile) {
confirmUnresolvedActorTypes(unresolvedLabels, () =>
exportAnimationDefinitionStub(group, definition, actorMetadata)
);
} else {
saveAnimationDefinitionFields(group, definition, actorMetadata);
Blockbench.showQuickMessage(`Saved animation definition fields for ${group.id}.`);
}
return true;
} catch (e) {
Blockbench.showMessageBox({
@@ -3537,6 +3543,21 @@
});
return false;
}
};
const dialog = new Dialog({
id: 'create_afw_animation_definition_review',
title: `Create Animation Definition: ${group.id}`,
width: 760,
form,
buttons: ['Cancel', 'Save', 'Create Animdef'],
cancelIndex: 0,
confirmIndex: 2,
onConfirm: (result) => submit(result, true),
onButton(index) {
if (index === 1) {
submit(dialog.getFormResult(), false);
return false;
}
},
});
dialog.show();
@@ -5413,28 +5434,36 @@
return soundLibrary;
}
function soundCueId(cue) {
function isPeakedSoundStage(animation) {
if (typeof Project === 'undefined' || !Project) return false;
const parsed = parseAfwStageAnimationName(animation);
if (!parsed) return false;
const stages = getSavedAnimdef(parsed.id).stages;
return Array.isArray(stages) && stages.some((stage) => stage.stage === parsed.stageNumber && stage.non_peak === true);
}
function soundCueId(cue, animation) {
const text = String(cue || '').trim();
const bare = text.replace(/^(?:needsofnature|minecraft):/, '');
if (bare.toLowerCase() === 'reactiveimpact_silent') return null;
const semantic = { reactiveimpact: 'impactdry01',
const semantic = { reactiveimpact: isPeakedSoundStage(animation) ? 'shot_in01' : 'impactdry01',
impactdry: 'impactdry01', impactwet: 'impactwet01', birth: 'pop' };
if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`;
const id = text.includes(':') ? text : `needsofnature:${text}`;
return importResourceId(id) ? id : null;
}
function resolveSoundAudio(cue, library = ensureSoundLibrary(), visiting = new Set()) {
function resolveSoundAudio(cue, library = ensureSoundLibrary(), visiting = new Set(), animation = null) {
if (/^random\(/i.test(String(cue).trim())) {
const match = String(cue).trim().match(/^random\((.*)\)$/i);
if (!match) return null;
for (const option of match[1].split(',').slice(0, 256)) {
const audio = resolveSoundAudio(option, library, visiting);
const audio = resolveSoundAudio(option, library, visiting, animation);
if (audio) return audio;
}
return null;
}
const id = soundCueId(cue);
const id = soundCueId(cue, animation);
if (!id || visiting.has(id) || visiting.size >= 16) return null;
const event = library.events.get(id);
if (!event) return null;
@@ -5446,7 +5475,7 @@
const resource = importResourceId(name);
if (!resource) continue;
if (object.type === 'event') {
const audio = resolveSoundAudio(name, library, next);
const audio = resolveSoundAudio(name, library, next, animation);
if (audio) return audio;
continue;
}
@@ -5468,15 +5497,18 @@
function mapAnimationSounds(animations) {
for (const animation of animations || []) {
const peaked = isPeakedSoundStage(animation);
for (const keyframe of animation?.animators?.effects?.sound || []) {
for (const point of keyframe.data_points || []) {
if (point.file && point.file !== point[SOUND_PREVIEW_PROPERTY]) continue;
const signature = `${soundLibraryGeneration}:${point.effect}:${point.sound}:${point.file}`;
const signature = `${soundLibraryGeneration}:${peaked}:${point.effect}:${point.sound}:${point.file}`;
if (soundMappedPoints.get(point) === signature) continue;
const audio = resolveSoundAudio(point.effect) || (point.sound ? resolveSoundAudio(point.sound) : null);
const library = ensureSoundLibrary();
const audio = resolveSoundAudio(point.effect, library, new Set(), animation)
|| (point.sound ? resolveSoundAudio(point.sound, library, new Set(), animation) : null);
point.file = audio || '';
point[SOUND_PREVIEW_PROPERTY] = point.file;
soundMappedPoints.set(point, `${soundLibraryGeneration}:${point.effect}:${point.sound}:${point.file}`);
soundMappedPoints.set(point, `${soundLibraryGeneration}:${peaked}:${point.effect}:${point.sound}:${point.file}`);
}
}
}
@@ -5516,7 +5548,7 @@
stopSoundAudition();
ensureSoundLibrary();
soundLibraryDialog?.delete();
soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'Sound Library', width: 700,
soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'NoN Sound Library', width: 700,
buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition,
component: {
data() { return { query: '', category: 'all', selected: '', page: 0,
@@ -5549,7 +5581,7 @@
},
preview() {
stopSoundAudition(); this.error = '';
const file = resolveSoundAudio(this.selected);
const file = resolveSoundAudio(this.selected, ensureSoundLibrary(), new Set(), typeof Animation === 'undefined' ? null : Animation.selected);
if (!file) { this.error = this.selected === 'reactiveimpact_silent' ? 'Silent cue: gameplay effects only.' : 'No audio found. Select the NoN JAR or an extracted assets folder.'; return; }
soundAudition = new Audio(file);
soundAudition.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100));
@@ -5638,6 +5670,7 @@
function beginFiguraSoundPlayback() {
installFiguraSoundCompat();
if (typeof Animation === 'undefined') return;
mapAnimationSounds(Animation.all);
for (const animation of Animation.all) {
const effects = animation.animators?.effects;
if (effects) for (const patch of figuraSoundPatches) {
@@ -5661,7 +5694,7 @@
const panel = document.getElementById('panel_keyframe');
if (panel && !document.getElementById('animationdirector_sound_library_button')) {
const button = document.createElement('button');
button.id = 'animationdirector_sound_library_button'; button.textContent = 'Sound Library...';
button.id = 'animationdirector_sound_library_button'; button.textContent = 'NoN Sound Library';
button.style.cssText = 'margin:8px;width:calc(100% - 16px)';
button.addEventListener('click', showSoundLibrary); panel.appendChild(button);
}
@@ -5688,7 +5721,7 @@ You can import or export complete AFW animation IDs as one file containing all s
});
}
soundLibraryAction = new Action('animationdirector_sound_library', {
name: 'Sound Library...', icon: 'library_music', category: 'animation',
name: 'NoN Sound Library', icon: 'library_music', category: 'animation',
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
click: showSoundLibrary,
});
+39 -1
View File
@@ -13,6 +13,7 @@ function createHarness() {
const dialogs = [];
const exports = [];
const errors = [];
let formResult;
const project = { multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
const context = {
Project: project,
@@ -20,6 +21,7 @@ function createHarness() {
Dialog: class {
constructor(options) { this.options = options; dialogs.push(options); }
show() {}
getFormResult() { return formResult ?? defaults(this.options.form); }
},
Blockbench: {
export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
@@ -43,7 +45,7 @@ function createHarness() {
context.adTest.showAnimationDefinitionReviewDialog(group, analysis);
return dialogs.at(-1);
}
return { open, exports, errors, project, analysis };
return { open, exports, errors, project, analysis, setFormResult(result) { formResult = result; } };
}
function defaults(form) {
@@ -52,6 +54,42 @@ function defaults(form) {
.map(([key, field]) => [key, field.value]));
}
test('Save retains definition fields in the project without exporting and restores them on reopen', () => {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
assert.deepEqual(Array.from(dialog.buttons), ['Cancel', 'Save', 'Create Animdef']);
assert.equal(dialog.confirmIndex, 2);
result.display_name = 'Saved definition';
result.content_tags = 'saved, tags';
result.weight = '3';
result.stub_stage_1_peaked = true;
result.stub_stage_2_peaked = false;
h.setFormResult(result);
assert.equal(dialog.onButton(1), false);
assert.equal(h.exports.length, 0);
assert.deepEqual(h.errors, []);
assert.equal(h.project.saved, false);
const reopened = h.open();
assert.equal(reopened.form.display_name.value, result.display_name);
assert.equal(reopened.form.content_tags.value, result.content_tags);
assert.equal(reopened.form.weight.value, 3);
assert.equal(reopened.form.stub_stage_1_peaked.value, true);
assert.equal(reopened.form.stub_stage_2_peaked.value, false);
assert.equal(reopened.onConfirm(defaults(reopened.form)), true);
assert.equal(h.exports[0].display_name, result.display_name);
});
test('invalid Save leaves previously saved fields intact', () => {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
result.display_name = 'Original';
h.setFormResult(result); dialog.onButton(1);
const original = h.project.animationdirector_animdef_definitions;
h.setFormResult({ ...result, display_name: 'Invalid', weight: '0' });
dialog.onButton(1);
assert.equal(h.project.animationdirector_animdef_definitions, original);
assert.equal(h.exports.length, 0);
assert.equal(h.errors.length, 1);
});
test('metadata-only playback stages remain editable and survive definition export', () => {
const h = createHarness();
h.analysis.stages = h.analysis.stages.slice(0, 1);
+26 -1
View File
@@ -107,7 +107,7 @@ test('animation menu orders import and creation actions and has no deprecated sp
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
assert.deepEqual(Array.from(menu.children, (action) => action.name), [
'Automatically Import Animation', 'Manual Import', 'Actor Preferences',
'Replace Actor Model', 'Block Requirements', 'Item Props', 'Sound Library...', 'Create Animation Definition', 'Export Animations',
'Replace Actor Model', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'Create Animation Definition', 'Export Animations',
]);
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
@@ -171,6 +171,31 @@ test('import prefills generator fields, builds guides and retains fields without
assert.equal(h.errors.length, 0);
});
test('importing an animdef replaces saved fields instead of retaining old settings', () => {
const h = harness();
h.project.multiactor_animdef_animators = 'Old author';
h.project.animationdirector_animdef_definitions = JSON.stringify({ test: {
display_name: 'Old', weight: 8, content_tags: ['old'], addon_data: { old: true },
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
stages: [{ stage: 1, non_peak: true }, { stage: 2 }],
}, other: { display_name: 'Unchanged' } });
const replacement = { display_name: 'Replacement',
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
stages: [{ stage: 1 }, { stage: 2, non_peak: true }],
};
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), replacement);
const saved = h.getSavedAnimdef('test');
assert.equal(saved.display_name, 'Replacement');
assert.equal(saved.weight, undefined);
assert.equal(saved.content_tags, undefined);
assert.equal(saved.addon_data, undefined);
assert.equal(h.getSavedAnimdef('other').display_name, 'Unchanged');
const dialog = h.dialogs.at(-1);
assert.equal(dialog.form.stub_stage_1_peaked.value, false);
assert.equal(dialog.form.stub_stage_2_peaked.value, true);
assert.equal(dialog.form.animators.value, '');
});
test('import maps explicit labels before positional fallbacks and updates actor references', () => {
const h = harness();
const source = { actors: [{ label: 'wolf', injector: 'V', injects_into: 'bottom' },
+39
View File
@@ -135,6 +135,45 @@ test('mapping retains cue IDs and manual previews, updates managed files and han
assert.equal(points[0].file, '');
});
test('reactive impact uses saved peak-stage metadata separately for each animation stage', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
const library = h.loadSoundLibrary();
h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: {
stages: [{ stage: 1 }, { stage: 2, non_peak: true }],
} }) };
const normal = { name: 'test.p1', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' }] }] } } };
const peaked = { name: 'test.p2', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' },
{ effect: 'reactiveimpact', file: 'manual.ogg' }, { effect: 'reactiveimpact_silent' }] }] } } };
h.mapAnimationSounds([normal, peaked]);
assert.equal(normal.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01'));
assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('shot_in01'));
assert.equal(peaked.animators.effects.sound[0].data_points[1].file, 'manual.ogg');
assert.equal(peaked.animators.effects.sound[0].data_points[2].file, '');
h.context.Animation.selected = peaked;
h.showSoundLibrary();
h.context.dialog.component.methods.preview.call({ selected: 'reactiveimpact', error: '' });
assert.equal(h.context.played[0].file, h.resolveSoundAudio('shot_in01'));
h.context.Project.animationdirector_animdef_definitions = JSON.stringify({ test: { stages: [{ stage: 2, non_peak: false }] } });
h.mapAnimationSounds([peaked]);
assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01'));
assert.equal(h.resolveSoundAudio('random(needsofnature:reactiveimpact)', library, new Set(), normal), h.resolveSoundAudio('impactdry01'));
});
test('missing stage metadata defaults to a normal impact; reused peak stages do not change the source stage', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
const library = h.loadSoundLibrary();
h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: {
stages: [{ stage: 1 }, { stage: 2, use_stage: 1, non_peak: true }],
} }) };
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p1' }), h.resolveSoundAudio('impactdry01'));
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p2' }), h.resolveSoundAudio('shot_in01'));
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'other.p3' }), h.resolveSoundAudio('impactdry01'));
});
test('insertion is undoable and preserves existing cues at the same time; Apply edits selection', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { one: { sounds: ['needsofnature:one'] } }, { one: 'one' }));