sound library

This commit is contained in:
2026-09-29 01:07:15 +02:00
parent 6d4094e7c4
commit 55d5593804
5 changed files with 378 additions and 20 deletions
+309 -13
View File
@@ -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: `<div style="padding:12px">
<div class="bar flex" style="gap:6px"><button @click="jar">Select NoN JAR / ZIP</button><button @click="folder">Select Assets Folder</button><button @click="reload">Refresh</button></div>
<p class="subtle">Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.</p>
<div class="bar flex" style="gap:8px;margin:10px 0"><input v-model="query" placeholder="Search sound IDs" style="flex:1" aria-label="Search sound IDs"><select v-model="category"><option value="all">All sounds</option><option>Sound events</option><option>Gameplay cues</option></select></div>
<label>Reactive preview <select v-model="mode" @change="modeChanged"><option value="dry">Dry</option><option value="wet">Wet</option><option value="full">Full</option><option value="peak">Peak</option></select></label>
<p class="subtle">Gameplay cues and random events use representative audio, not a gameplay simulation.</p>
<div style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)">
<div v-for="row in visibleRows" :key="row.category+row.id" @click="selected=row.id;stop()" @dblclick="selected=row.id;preview()" :style="{background:selected===row.id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer'}"><span>{{row.id}}</span><small style="float:right;opacity:.6">{{row.category}}</small></div>
</div>
<div class="bar flex" style="gap:6px"><button @click="page--" :disabled="page===0">Previous</button><span>{{rows.length}} results</span><button @click="page++" :disabled="(page+1)*100>=rows.length">Next</button></div>
<div class="bar flex" style="gap:6px;margin-top:12px"><button @click="preview" :disabled="!selected">Play</button><button @click="stop">Stop</button><button @click="apply(false)" :disabled="!selected">Apply to Selected / Insert</button><button @click="apply(true)" :disabled="!selected">Insert at Playhead</button></div>
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
</div>`,
},
});
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);
+28
View File
@@ -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.
+14 -4
View File
@@ -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', () => {
+1 -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', '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',
+26 -2
View File
@@ -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: [