diff --git a/AnimationDirector.js b/AnimationDirector.js index 6c24fa9..17623ae 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -25,6 +25,13 @@ const propMeshes = new Map(); const propSources = new Map(); let propPreviewSignature = null, propPreviewGeneration = 0; + const SOUND_SOURCE_KEY = 'animationdirector_sound_source'; + const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview'; + const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection']; + let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition; + let soundLibrary, soundLibraryGeneration = 0, soundPreviewMode = 'dry'; + const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap(); + let soundAudioBytes = 0; const SYSTEM_TAG_OPTIONS = { '': 'None (normal animation)', manualpeak: 'Manual peak', @@ -136,7 +143,7 @@ function setActorUvBasis(actorIndex, width, height) { const all = getActorUvBasis(); if (all[actorIndex]?.width === width && all[actorIndex]?.height === height) return; - all[actorIndex] = { width, height }; + all[actorIndex] = { ...all[actorIndex], width, height }; Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all); Project.saved = false; } @@ -606,20 +613,34 @@ return true; } - function normalizeActorUvs(actorIndex, width, height, original = null) { + function normalizeCubeUvs(cube, actorIndex, width, height, original = null) { width = validUvDimension(width); height = validUvDimension(height); - const basis = original || getActorUvBasis()[actorIndex] || { + const all = getActorUvBasis(), actor = all[actorIndex]; + const key = cube.uuid || cube.name; + const basis = original || actor?.cubes?.[key] || actor || { width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) }; const oldWidth = validUvDimension(basis.width), oldHeight = validUvDimension(basis.height); expandProjectUvs(width, height); const x = oldWidth / width, y = oldHeight / height; - if (x !== 1 || y !== 1) { - for (const cube of Cube.all) if (cubeBelongsToActor(cube, actorIndex)) scaleCubeUvs(cube, x, y); + scaleCubeUvs(cube, x, y); + if (actor?.cubes?.[key]?.width !== width || actor?.cubes?.[key]?.height !== height) { + all[actorIndex] = { ...(actor || basis), cubes: { ...actor?.cubes, [key]: { width, height } } }; + Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all); + Project.saved = false; } - setActorUvBasis(actorIndex, width, height); return x !== 1 || y !== 1; } + function normalizeActorUvs(actorIndex, width, height, original = null) { + let changed = false; + for (const cube of Cube.all) { + if (cubeBelongsToActor(cube, actorIndex)) changed = normalizeCubeUvs(cube, actorIndex, width, height, original) || changed; + } + expandProjectUvs(validUvDimension(width), validUvDimension(height)); + setActorUvBasis(actorIndex, width, height); + return changed; + } + // ---------------------------- // Block requirement authoring // ---------------------------- @@ -1843,6 +1864,11 @@ function applyTextureToCube(cube, texture) { if (!cube || !texture) return; + const actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name); + const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || texture.uv_width || 0; + const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || texture.uv_height || 0; + if (actorIndex && imageWidth > 0 && imageHeight > 0) normalizeCubeUvs(cube, actorIndex, imageWidth, imageHeight); + const faces = ['north', 'south', 'east', 'west', 'up', 'down']; const textureId = texture.uuid || texture.id || texture.name; @@ -1869,14 +1895,13 @@ const texture = findTextureByActorTextureName(textureName); if (!texture) return { applied: 0, missing: true }; - const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || 0; - const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || 0; - if (imageWidth > 0 && imageHeight > 0) normalizeActorUvs(actorIndex, imageWidth, imageHeight); - let applied = 0; + const overrides = getProjectBoneTextureOverrides(); const cubes = Array.isArray(Cube.all) ? Cube.all : []; for (const cube of cubes) { if (!cubeBelongsToActor(cube, actorIndex)) continue; + const override = getNearestBoneTextureOverride(cube, overrides); + if (override?.texture && findTextureByActorTextureName(override.texture)) continue; applyTextureToCube(cube, texture); applied++; } @@ -2439,6 +2464,16 @@ return { groups, ignored }; } + function cleanSoundPreviewFields(clip) { + for (const cues of Object.values(clip?.sound_effects || {})) { + for (const cue of Array.isArray(cues) ? cues : [cues]) { + if (!cue || typeof cue !== 'object') continue; + delete cue.file; delete cue[SOUND_PREVIEW_PROPERTY]; + } + } + return clip; + } + function compileConjoinedStage(stage) { const animation = stage?.animation; if (typeof animation?.compileBedrockAnimation !== 'function') { @@ -2447,7 +2482,7 @@ const compiled = animation.compileBedrockAnimation() || {}; if (!compiled.animations) { - return { formatVersion: '1.8.0', clip: stripBlockRequirementGuideBones(clone(compiled)) }; + return { formatVersion: '1.8.0', clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(compiled))) }; } let clip = compiled.animations[stage.name]; @@ -2460,7 +2495,7 @@ } return { formatVersion: compiled.format_version || '1.8.0', - clip: stripBlockRequirementGuideBones(clone(clip)), + clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(clip))), }; } @@ -3936,6 +3971,7 @@ content: JSON.stringify(imported.fileObj, null, 2), }; Animator.loadFile(fakeFile, imported.stageNames); + mapAnimationSounds(Animation.all); Blockbench.showQuickMessage( `Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`, 2500 @@ -5132,7 +5168,6 @@ const base = row.textures.get(row.defaultTexture); if (base) { textures[index] = loadedTextures.get(base.file || `${base.relative}:${row.defaultTexture}`); - if (base.dimensions) normalizeActorUvs(index, base.dimensions.width, base.dimensions.height); } for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) { const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`); @@ -5147,6 +5182,7 @@ setProjectBoneTextureOverrides(overrides); setProjectAnimdefAnimators(plan.definition.animators || []); Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames); + mapAnimationSounds(Animation.all); rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false); if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries); Canvas.updateAll(); @@ -5313,6 +5349,248 @@ } } + // Sound IDs remain gameplay cues. Blob URLs are editor-only, lazily loaded audio previews. + function stopSoundAudition() { + if (soundAudition) { soundAudition.pause(); soundAudition = null; } + } + + function clearSoundAudio() { + stopSoundAudition(); + for (const url of soundAudioUrls.values()) URL.revokeObjectURL(url); + soundAudioUrls.clear(); soundAudioBytes = 0; + } + + function soundJsonPaths(source, io) { + if (source.files) return [...source.files.keys()].filter((name) => /^assets\/[^/]+\/sounds\.json$/.test(name)); + const root = io.path.basename(source.path) === 'assets' ? io.path.dirname(source.path) : source.path; + const assets = io.path.join(root, 'assets'); + if (!io.fs.existsSync(assets)) return []; + return io.fs.readdirSync(assets, { withFileTypes: true }).slice(0, 256) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && /^[a-z0-9_.-]+$/.test(entry.name)) + .map((entry) => `assets/${entry.name}/sounds.json`); + } + + function loadSoundLibrary(io = desktopFiles()) { + clearSoundAudio(); + const events = new Map(), sources = [], warnings = []; + const selected = localStorage.getItem(SOUND_SOURCE_KEY); + const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : [])]); + for (const file of paths.slice(0, 64)) { + try { + const source = openPropSource(file, io); + sources.push(source); + for (const name of soundJsonPaths(source, io)) { + const raw = source.read(name); + if (!raw) continue; + const json = JSON.parse(raw.toString('utf8').replace(/^\uFEFF/, '')); + const namespace = name.split('/')[1]; + for (const [key, value] of Object.entries(json)) { + const id = `${namespace}:${key}`; + if (!importResourceId(id) || !Array.isArray(value?.sounds) || events.size >= 10000) continue; + const entries = value.sounds.slice(0, 256); + if (!events.has(id)) events.set(id, { entries, replace: value.replace === true }); + else if (!events.get(id).replace) { + events.get(id).entries.push(...entries); + events.get(id).replace = value.replace === true; + } + } + } + } catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); } + } + soundLibrary = { events, sources, warnings }; + soundLibraryGeneration++; + return soundLibrary; + } + + function ensureSoundLibrary() { + if (!soundLibrary) { + try { loadSoundLibrary(); } + catch (_) { soundLibrary = { events: new Map(), sources: [], warnings: ['Sound assets require Blockbench Desktop.'] }; } + } + return soundLibrary; + } + + function soundCueId(cue, mode = soundPreviewMode) { + const text = String(cue || '').trim(); + const bare = text.replace(/^(?:needsofnature|minecraft):/, ''); + if (bare.toLowerCase() === 'reactiveimpact_silent') return null; + const semantic = { reactiveimpact: { dry: 'impactdry01', wet: 'impactwet01', full: 'wet04', peak: 'shot_in01' }[mode] || '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()) { + 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); + if (audio) return audio; + } + return null; + } + const id = soundCueId(cue); + if (!id || visiting.has(id) || visiting.size >= 16) return null; + const event = library.events.get(id); + if (!event) return null; + const next = new Set(visiting); next.add(id); + for (const entry of event.entries) { + const object = typeof entry === 'string' ? { name: entry } : entry; + if (!object || typeof object.name !== 'string') continue; + const name = object.name.includes(':') ? object.name : `minecraft:${object.name}`; + const resource = importResourceId(name); + if (!resource) continue; + if (object.type === 'event') { + const audio = resolveSoundAudio(name, library, next); + if (audio) return audio; + continue; + } + const asset = `assets/${resource.namespace}/sounds/${resource.resource}.ogg`; + if (soundAudioUrls.has(asset)) return soundAudioUrls.get(asset); + for (const source of library.sources) { + try { + const bytes = source.read(asset); + if (!bytes) continue; + if (soundAudioBytes + bytes.length > 64 * 1024 * 1024) return null; + const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/ogg' })); + soundAudioUrls.set(asset, url); soundAudioBytes += bytes.length; + return url; + } catch (_) { /* An unavailable asset must never prevent animation import. */ } + } + } + return null; + } + + function mapAnimationSounds(animations) { + for (const animation of animations || []) { + 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}:${soundPreviewMode}:${point.effect}:${point.sound}:${point.file}`; + if (soundMappedPoints.get(point) === signature) continue; + const audio = resolveSoundAudio(point.effect) || (point.sound ? resolveSoundAudio(point.sound) : null); + point.file = audio || ''; + point[SOUND_PREVIEW_PROPERTY] = point.file; + soundMappedPoints.set(point, `${soundLibraryGeneration}:${soundPreviewMode}:${point.effect}:${point.sound}:${point.file}`); + } + } + } + } + + function setSoundPreviewMode(mode) { + soundPreviewMode = ['dry', 'wet', 'full', 'peak'].includes(mode) ? mode : 'dry'; + mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); + } + + function applyLibrarySound(id, insert = false) { + const animation = typeof Animation === 'undefined' ? null : Animation.selected; + if (!animation) throw new Error('Select an animation first.'); + const selected = (Timeline.selected || []).filter((keyframe) => keyframe.channel === 'sound' && keyframe.animator?.animation === animation); + if (!insert && selected.length) { + Undo.initEdit({ keyframes: selected }); + for (const keyframe of selected) { + const point = keyframe.data_points[0]; + point.effect = id; point.file = ''; point[SOUND_PREVIEW_PROPERTY] = ''; + } + mapAnimationSounds([animation]); + Undo.finishEdit('Apply sound from library'); + } else { + const effects = animation.animators.effects || (animation.animators.effects = new EffectAnimator(animation)); + const keyframes = effects.sound.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001); + Undo.initEdit({ keyframes }); + let keyframe = keyframes[0]; + if (keyframe) { + keyframe.extend({ data_points: [...keyframe.data_points.map((point) => point.getUndoCopy()), { effect: id }] }); + } else { + keyframe = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'sound', false, false); + keyframes.push(keyframe); + } + mapAnimationSounds([animation]); + effects.select(); keyframe.select(); + Undo.finishEdit('Insert sound from library', { keyframes }); + } + if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection(); + } + + function showSoundLibrary() { + stopSoundAudition(); + ensureSoundLibrary(); + soundLibraryDialog?.delete(); + soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'Sound Library', width: 700, + buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition, + component: { + data() { return { query: '', category: 'all', selected: '', mode: soundPreviewMode, page: 0, + ids: [...soundLibrary.events.keys()].sort(), warnings: soundLibrary.warnings.join('\n'), error: '' }; }, + computed: { + rows() { + const cues = ['reactiveimpact', 'reactiveimpact_silent', 'impactdry', 'impactwet', 'birth']; + return [...cues.map((id) => ({ id, category: 'Gameplay cues' })), ...this.ids.map((id) => ({ id, category: 'Sound events' }))] + .filter((entry) => (this.category === 'all' || entry.category === this.category) && entry.id.toLowerCase().includes(this.query.toLowerCase())); + }, + visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); }, + }, + watch: { query() { this.page = 0; }, category() { this.page = 0; } }, + methods: { + reload() { + try { loadSoundLibrary(); this.ids = [...soundLibrary.events.keys()].sort(); this.warnings = soundLibrary.warnings.join('\n'); + this.error = ''; mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); } + catch (error) { this.error = error.message; } + }, + folder() { + try { const folder = Blockbench.pickDirectory({ title: 'Select folder containing assets', resource_id: SOUND_SOURCE_KEY }); + if (folder) { localStorage.setItem(SOUND_SOURCE_KEY, folder); this.reload(); } } + catch (error) { this.error = error.message; } + }, + jar() { + Filesystem.importFile({ type: 'Sound assets', extensions: ['jar', 'zip'], readtype: 'none', multiple: false }, (files) => { + if (files?.[0]?.path) { localStorage.setItem(SOUND_SOURCE_KEY, files[0].path); this.reload(); } + }); + }, + preview() { + stopSoundAudition(); this.error = ''; + const file = resolveSoundAudio(this.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)); + soundAudition.play().catch((error) => { this.error = error.message; }); + }, + stop: stopSoundAudition, + apply(insert) { try { applyLibrarySound(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } }, + modeChanged() { stopSoundAudition(); setSoundPreviewMode(this.mode); }, + }, + template: `
+
+

Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.

+
+ +

Gameplay cues and random events use representative audio, not a gameplay simulation.

+
+
{{row.id}}{{row.category}}
+
+
{{rows.length}} results
+
+

{{error}}

{{warnings}}

+
`, + }, + }); + soundLibraryDialog.show(); + } + + function refreshSoundLibraryPanel() { + if (typeof document !== 'undefined') { + 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.style.cssText = 'margin:8px;width:calc(100% - 16px)'; + button.addEventListener('click', showSoundLibrary); panel.appendChild(button); + } + } + mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]); + } + Plugin.register(PLUGIN_ID, { title: 'AnimationDirector', author: 'L1Z0', @@ -5326,6 +5604,19 @@ You can import or export complete AFW animation IDs as one file containing all s version: '1.1.0', variant: 'both', onload() { + if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') { + soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, { + exposed: false, condition: (point) => point.keyframe.channel === 'sound', + }); + } + soundLibraryAction = new Action('animationdirector_sound_library', { + name: 'Sound Library...', icon: 'library_music', category: 'animation', + description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.', + click: showSoundLibrary, + }); + soundLibraryListener = refreshSoundLibraryPanel; + Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener); + refreshSoundLibraryPanel(); if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) { if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) { actorNamesProjectProperty = new Property(ModelProject, 'string', ACTOR_NAMES_PROPERTY, { @@ -5514,6 +5805,7 @@ You can import or export complete AFW animation IDs as one file containing all s replaceActorAction, blockRequirementAction, propAction, + soundLibraryAction, createAnimationDefinitionAction, exportConjoinedAction, ], @@ -5528,6 +5820,10 @@ You can import or export complete AFW animation IDs as one file containing all s MenuBar.update(); }, onunload() { + for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener); + if (typeof document !== 'undefined') document.getElementById('animationdirector_sound_library_button')?.remove(); + soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete(); + clearSoundAudio(); soundLibrary = undefined; clearPropMeshes(); propSources.clear(); propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete(); if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener); diff --git a/README.md b/README.md index e69de29..5b172da 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,28 @@ +# AnimationDirector + +## Sound Library + +Open **Sound Library...** in the keyframe panel or **Tools > AnimationDirector Anims**. +Select a NoN mod JAR/ZIP or a folder containing `assets` once. The plugin remembers +this source and also reads sound registries from your configured **Pack Folders**. +Pack folders use their configured priority, followed by the selected sound source. +No sound assets are downloaded or bundled into the plugin. + +Search sound IDs, select a result and press **Play** (or double-click it) to audition. +**Apply to Selected / Insert** changes the first cue of selected sound keyframes, +or inserts at the playhead when no sound keyframe is selected. **Insert at Playhead** +always inserts; existing cues at that time are retained. + +Recognized sound cues are automatically linked to timeline audio when importing +animations, opening projects or selecting animations. Select the sound source first, +or select it afterwards to map already imported animations. **Refresh** reloads changed +files. Manually assigned audio previews and unknown gameplay cues are preserved. + +**Reactive preview** selects representative Dry/Wet/Full/Peak audio for +`reactiveimpact`. `reactiveimpact_silent` has no audio. Random sound events/pools use +the first available sample. These previews do not simulate gameplay; exported +animation cue IDs remain unchanged. Blockbench's native timeline player previews +the first cue at a sound keyframe; additional simultaneous cues still export normally. + +Sound browsing requires Blockbench Desktop. Preview audio is loaded on demand, +cached with a 64 MiB limit, and kept out of exported animation JSON. diff --git a/test/automatic-import.test.js b/test/automatic-import.test.js index 16c9ad1..623d8ff 100644 --- a/test/automatic-import.test.js +++ b/test/automatic-import.test.js @@ -31,7 +31,7 @@ function harness(capture = false) { add(undo) { assert.equal(undo, false); Texture.all.push(this); } } const context = { - Buffer, Timeline: { animation: null }, + Buffer, Blob, URL: { createObjectURL() { return 'blob:import-preview'; }, revokeObjectURL() {} }, Timeline: { animation: null }, Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] }, Plugin: { register() {} }, requireNativeModule: require, localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) }, @@ -49,7 +49,10 @@ function harness(capture = false) { Animator: { loadFile(file, names) { context.loadedAnimation = JSON.parse(file.content); - context.Animation.all = names.map((name) => ({ name, select() {} })); + context.Animation.all = names.map((name) => ({ name, select() {}, animators: { effects: { sound: + Object.entries(context.loadedAnimation.animations[name]?.sound_effects || {}).map(([time, cues]) => ({ + time: Number(time), data_points: Array.isArray(cues) ? cues : [cues], + })) } } })); }, }, Animation: { all: [] }, Canvas: { updateAll() {}, updateView() {} }, @@ -313,7 +316,8 @@ test('complete import finds model texture in cached assets and uses its PNG dime importing.commitAutomaticImport(h.context.importedPlan); assert.equal(importing.context.Texture.all[0].uv_width, 32); assert.equal(importing.context.Texture.all[0].uv_height, 64); - assert.equal(JSON.parse(importing.context.Project.animationdirector_actor_uv_basis)['1'].width, 32); + assert.equal(JSON.parse(importing.context.Project.animationdirector_actor_uv_basis)['1'].width, 64, + 'The actor retains its geometry basis; each textured cube tracks its own normalization'); assert.equal(JSON.parse(importing.context.Project.multiactor_actor_textures)['1'], row.defaultTexture); }); @@ -494,10 +498,13 @@ test('missing models can be supplied manually, and their locally supplied textur test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => { const h = harness(); + h.storage.set('animationdirector_sound_source', path.resolve(__dirname, '../../NON/src/main/resources')); const plan = { parsed: { animationId: 'example' }, blockEntry: null, propEntries: { '["example","wolf","right",0]': { animation: 'example', item: 'minecraft:carrot', mode: 'item' } }, definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] }, - imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': { bones: { actor1_body: { rotation: [1, 2, 3] } } } } } }, + imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': { + bones: { actor1_body: { rotation: [1, 2, 3] } }, sound_effects: { '0.5': { effect: 'reactiveimpact' } }, + } } } }, rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } }, geo: { bones: [{ name: 'body' }] }, defaultTexture: 'test:textures/wolf.png', textures: new Map([['test:textures/wolf.png', { file: 'wolf.png' }]]) }] }; @@ -511,6 +518,9 @@ test('batch import uses a new project, one undo entry, actor prefixes and textur assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png'); assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example); assert.equal(JSON.parse(h.context.Project.animationdirector_prop_previews)['["example","wolf","right",0]'].item,'minecraft:carrot'); + const cue = h.context.Animation.all[0].animators.effects.sound[0].data_points[0]; + assert.equal(cue.effect, 'reactiveimpact'); + assert.equal(cue.file, 'blob:import-preview'); }); test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => { diff --git a/test/block-requirements.test.js b/test/block-requirements.test.js index c9966b0..7e1b9a4 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', 'Create Animation Definition', 'Export Animations', + 'Replace Actor Model', 'Block Requirements', 'Item Props', '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', diff --git a/test/uv-normalization.test.js b/test/uv-normalization.test.js index 5c5e9ac..3df1b06 100644 --- a/test/uv-normalization.test.js +++ b/test/uv-normalization.test.js @@ -8,7 +8,7 @@ const vm = require('node:vm'); function harness() { const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( ' Plugin.register(PLUGIN_ID, {', - ' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject };\n Plugin.register(PLUGIN_ID, {' + ' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject, applyAllActorTexturePreviews, setProjectActorTextures, setProjectBoneTextureOverrides };\n Plugin.register(PLUGIN_ID, {' ); class Group { static all = []; @@ -46,7 +46,7 @@ function harness() { setTimeout(callback) { callback(); }, Undo: { initEdit() {}, finishEdit() {}, cancelEdit() {} }, Project: { texture_width: 64, texture_height: 64, box_uv: true }, - Canvas: { updateAllUVs() {} }, Plugin: { register() {} }, + Canvas: { updateAllUVs() {}, updateAll() {} }, Plugin: { register() {} }, localStorage: { getItem() { return null; } }, }; vm.runInNewContext(source, context); @@ -82,6 +82,30 @@ test('assigning a manually imported texture to an actor normalizes only that act assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]); }); +test('bone texture dimensions override the base without repeated UV scaling', () => { + const h = harness(); + const group = new h.context.Group({ name: 'actor1_wind', uuid: 'wind' }); + group.init(); + const baseCube = new h.Cube('actor1_base'), windCube = new h.Cube('actor1_wind_cube'); + windCube.addTo(group); + h.context.Project.texture_width = h.context.Project.texture_height = 128; + h.normalizeActorUvs(1, 128, 128); + for (const [name, size] of [['vanilla', 32], ['wind', 128]]) { + new h.Texture({ name, uv_width: size, uv_height: size }).add(); + } + h.setProjectActorTextures({ 1: 'vanilla' }); + h.setProjectBoneTextureOverrides({ wind: { uuid: 'wind', name: group.name, texture: 'wind' } }); + h.applyAllActorTexturePreviews(); + assert.deepEqual(Array.from(baseCube.faces.north.uv), [0, 0, 64, 64]); + assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]); + assert.equal(windCube.faces.north.texture, 'wind'); + h.applyAllActorTexturePreviews(); + assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]); + h.setProjectBoneTextureOverrides({}); + h.applyAllActorTexturePreviews(); + assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 64, 64]); +}); + test('importing a larger actor preserves UV mapping of the earlier model', () => { const h = harness(); const geometry = (width) => ({ description: { texture_width: width, texture_height: width }, bones: [{ name: 'body', cubes: [