From 53a5c475494158f6500e7a2ff46d7b319a1b6253 Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Tue, 29 Sep 2026 01:58:15 +0200 Subject: [PATCH] tweaks and improvements --- AnimationDirector.js | 91 ++++++++++++++++++++++----------- test/animdef-generator.test.js | 40 ++++++++++++++- test/block-requirements.test.js | 27 +++++++++- test/sound-library.test.js | 39 ++++++++++++++ 4 files changed, 166 insertions(+), 31 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index f007b09..832c09c 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -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//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//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, }); diff --git a/test/animdef-generator.test.js b/test/animdef-generator.test.js index d93f63e..6200d76 100644 --- a/test/animdef-generator.test.js +++ b/test/animdef-generator.test.js @@ -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); diff --git a/test/block-requirements.test.js b/test/block-requirements.test.js index 7e1b9a4..836fb9e 100644 --- a/test/block-requirements.test.js +++ b/test/block-requirements.test.js @@ -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' }, diff --git a/test/sound-library.test.js b/test/sound-library.test.js index 36e9d2b..1498882 100644 --- a/test/sound-library.test.js +++ b/test/sound-library.test.js @@ -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' }));