particle library
This commit is contained in:
+157
-2
@@ -39,6 +39,11 @@
|
||||
let soundLibrary, soundLibraryGeneration = 0;
|
||||
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
|
||||
let soundAudioBytes = 0;
|
||||
const PARTICLE_SOURCE_KEY = 'animationdirector_particle_source';
|
||||
const NON_PARTICLE_IDS = ['liquid_particle', 'liquid_particle_falling', 'liquid_particle_puddle',
|
||||
'liquid_particle_water', 'smallheart', 'ripped_fabric', 'spray_splash', 'spray_surface_drip',
|
||||
'spray_surface_viscous', 'spray_surface_liquid'].map((name) => `needsofnature:${name}`);
|
||||
let particleLibraryAction, particleLibraryDialog, particleLibrary;
|
||||
const figuraSoundPatches = [], figuraSoundMedia = new Set();
|
||||
let figuraSoundCompatEnabled = true, figuraSoundPlayListener;
|
||||
const SYSTEM_TAG_OPTIONS = {
|
||||
@@ -6130,6 +6135,133 @@
|
||||
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
||||
}
|
||||
|
||||
function particleJsonPaths(source, io) {
|
||||
if (source.files) return [...source.files.keys()].filter((name) => /^assets\/[a-z0-9_.-]+\/particles\/[a-z0-9_./-]+\.json$/.test(name)).slice(0, 10000);
|
||||
const root = io.path.basename(source.path) === 'assets' ? io.path.dirname(source.path) : source.path;
|
||||
const assets = io.path.join(root, 'assets'), paths = [];
|
||||
if (!io.fs.existsSync(assets)) return paths;
|
||||
let visited = 0;
|
||||
const walk = (folder, relative, depth) => {
|
||||
if (depth > 8 || visited >= 20000) return;
|
||||
for (const entry of io.fs.readdirSync(folder, { withFileTypes: true })) {
|
||||
if (++visited > 20000 || paths.length >= 10000) break;
|
||||
if (entry.isSymbolicLink() || !/^[a-z0-9_.-]+$/.test(entry.name)) continue;
|
||||
const name = `${relative}/${entry.name}`;
|
||||
if (entry.isDirectory()) walk(io.path.join(folder, entry.name), name, depth + 1);
|
||||
else if (entry.isFile() && entry.name.endsWith('.json')) paths.push(name);
|
||||
}
|
||||
};
|
||||
for (const entry of io.fs.readdirSync(assets, { withFileTypes: true }).slice(0, 256)) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[a-z0-9_.-]+$/.test(entry.name)) continue;
|
||||
const folder = io.path.join(assets, entry.name, 'particles');
|
||||
if (io.fs.existsSync(folder) && io.fs.lstatSync(folder).isDirectory() && !io.fs.lstatSync(folder).isSymbolicLink()) {
|
||||
walk(folder, `assets/${entry.name}/particles`, 0);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function loadParticleLibrary(io = desktopFiles()) {
|
||||
const ids = new Set(NON_PARTICLE_IDS), warnings = [];
|
||||
const selected = localStorage.getItem(PARTICLE_SOURCE_KEY) || localStorage.getItem(SOUND_SOURCE_KEY);
|
||||
const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : []),
|
||||
...discoverPropSources(io).map((source) => source.file)]);
|
||||
for (const file of paths.slice(0, 64)) {
|
||||
try {
|
||||
const source = openPropSource(file, io);
|
||||
// The asset name identifies a Java particle; its JSON is a sprite list, not a preview definition.
|
||||
for (const name of particleJsonPaths(source, io)) {
|
||||
const match = name.match(/^assets\/([^/]+)\/particles\/(.+)\.json$/);
|
||||
const id = match && `${match[1]}:${match[2]}`;
|
||||
if (id && importResourceId(id) && ids.size < 10000) ids.add(id);
|
||||
}
|
||||
} catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); }
|
||||
}
|
||||
particleLibrary = { ids: [...ids].sort(), warnings };
|
||||
return particleLibrary;
|
||||
}
|
||||
|
||||
function applyLibraryParticle(id, insert = false) {
|
||||
if (!importResourceId(id)) throw new Error('Invalid particle ID.');
|
||||
const animation = typeof Animation === 'undefined' ? null : Animation.selected;
|
||||
if (!animation) throw new Error('Select an animation first.');
|
||||
const selected = (Timeline.selected || []).filter((frame) => frame.channel === 'particle' && frame.animator?.animation === animation);
|
||||
if (!insert && selected.length) {
|
||||
Undo.initEdit({ keyframes: selected });
|
||||
for (const frame of selected) {
|
||||
const point = frame.data_points[0];
|
||||
if (point.effect !== id) point.file = '';
|
||||
point.effect = id;
|
||||
}
|
||||
Undo.finishEdit('Apply particle from library');
|
||||
} else {
|
||||
const effects = animation.animators.effects || (animation.animators.effects = new EffectAnimator(animation));
|
||||
const keyframes = effects.particle.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001);
|
||||
Undo.initEdit({ keyframes });
|
||||
let frame = keyframes[0];
|
||||
if (frame) frame.extend({ data_points: [...frame.data_points.map((point) => point.getUndoCopy()), { effect: id }] });
|
||||
else {
|
||||
frame = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'particle', false, false);
|
||||
keyframes.push(frame);
|
||||
}
|
||||
effects.select(); frame.select();
|
||||
Undo.finishEdit('Insert particle from library', { keyframes });
|
||||
}
|
||||
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
||||
}
|
||||
|
||||
function showParticleLibrary() {
|
||||
if (!particleLibrary) {
|
||||
try { loadParticleLibrary(); }
|
||||
catch (error) { particleLibrary = { ids: [...NON_PARTICLE_IDS].sort(), warnings: [error.message] }; }
|
||||
}
|
||||
particleLibraryDialog?.delete();
|
||||
particleLibraryDialog = new Dialog({ id: 'animationdirector_particle_library', title: 'NoN Particle Library', width: 700,
|
||||
buttons: ['Close'], component: {
|
||||
data() { return { query: '', category: 'all', selected: '', page: 0,
|
||||
ids: particleLibrary.ids, warnings: particleLibrary.warnings.join('\n'), error: '' }; },
|
||||
computed: {
|
||||
categories() { return [...new Set(this.ids.map((id) => id.split(':')[0]))].sort(); },
|
||||
rows() { return this.ids.filter((id) => (this.category === 'all' || id.startsWith(`${this.category}:`))
|
||||
&& 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 { loadParticleLibrary(); this.ids = particleLibrary.ids; this.warnings = particleLibrary.warnings.join('\n');
|
||||
this.page = 0; this.error = ''; }
|
||||
catch (error) { this.error = error.message; }
|
||||
},
|
||||
folder() {
|
||||
try { const folder = Blockbench.pickDirectory({ title: 'Select folder containing assets', resource_id: PARTICLE_SOURCE_KEY });
|
||||
if (folder) { localStorage.setItem(PARTICLE_SOURCE_KEY, folder); this.reload(); } }
|
||||
catch (error) { this.error = error.message; }
|
||||
},
|
||||
jar() {
|
||||
Filesystem.importFile({ type: 'Particle assets', extensions: ['jar', 'zip'], readtype: 'none', multiple: false }, (files) => {
|
||||
if (files?.[0]?.path) { localStorage.setItem(PARTICLE_SOURCE_KEY, files[0].path); this.reload(); }
|
||||
});
|
||||
},
|
||||
apply(insert) { try { applyLibraryParticle(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } },
|
||||
},
|
||||
template: `<div style="padding:12px">
|
||||
<div class="bar flex" style="gap:6px"><button @click="jar">Select JAR / ZIP</button><button @click="folder">Select Assets Folder</button><button @click="reload">Refresh</button></div>
|
||||
<p class="subtle">Includes NoN particles, remembered Pack Folders, and locally available Minecraft assets. Select a mod JAR to add its particles.</p>
|
||||
<div class="bar flex" style="gap:8px;margin:10px 0"><input v-model="query" placeholder="Search particle IDs" style="flex:1" aria-label="Search particle IDs"><select v-model="category"><option value="all">All particles</option><option v-for="namespace in categories" :key="namespace" :value="namespace">{{namespace}}</option></select></div>
|
||||
<div style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)">
|
||||
<div v-for="id in visibleRows" :key="id" @click="selected=id" :style="{background:selected===id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer'}">{{id}}</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="apply(false)" :disabled="!selected">Apply to Selected / Insert</button><button @click="apply(true)" :disabled="!selected">Insert at Playhead</button></div>
|
||||
<p class="subtle">Set the spawn locator in the keyframe panel. This library assigns particle IDs only, without previews. Parameterized particles require a NoN addon resolver; their ID alone may not spawn them.</p>
|
||||
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
|
||||
</div>`,
|
||||
},
|
||||
});
|
||||
particleLibraryDialog.show();
|
||||
}
|
||||
|
||||
function showSoundLibrary() {
|
||||
stopSoundAudition();
|
||||
ensureSoundLibrary();
|
||||
@@ -6276,14 +6408,29 @@
|
||||
|
||||
function refreshSoundLibraryPanel() {
|
||||
installFiguraSoundCompat();
|
||||
if (typeof document !== 'undefined' && !Blockbench.isWeb) {
|
||||
if (typeof document !== 'undefined') {
|
||||
const panel = document.getElementById('panel_keyframe');
|
||||
if (panel && !document.getElementById('animationdirector_sound_library_button')) {
|
||||
const existing = document.getElementById('animationdirector_sound_library_button');
|
||||
const soundSelection = typeof Timeline !== 'undefined' && Timeline.selected?.length > 0
|
||||
&& Timeline.selected.every((keyframe) => keyframe.channel === 'sound');
|
||||
if (!soundSelection || Blockbench.isWeb) {
|
||||
existing?.remove();
|
||||
} else if (panel && !existing) {
|
||||
const button = document.createElement('button');
|
||||
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);
|
||||
}
|
||||
const particleButton = document.getElementById('animationdirector_particle_library_button');
|
||||
const particleSelection = typeof Timeline !== 'undefined' && Timeline.selected?.length > 0
|
||||
&& Timeline.selected.every((keyframe) => keyframe.channel === 'particle');
|
||||
if (!particleSelection || Blockbench.isWeb) particleButton?.remove();
|
||||
else if (panel && !particleButton) {
|
||||
const button = document.createElement('button');
|
||||
button.id = 'animationdirector_particle_library_button'; button.textContent = 'NoN Particle Library';
|
||||
button.style.cssText = 'margin:8px;width:calc(100% - 16px)';
|
||||
button.addEventListener('click', showParticleLibrary); panel.appendChild(button);
|
||||
}
|
||||
}
|
||||
mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]);
|
||||
}
|
||||
@@ -6379,6 +6526,11 @@ Features without a reference need no additional addon. Whole-animation import an
|
||||
condition: () => !Blockbench.isWeb,
|
||||
});
|
||||
soundLibraryListener = refreshSoundLibraryPanel;
|
||||
particleLibraryAction = new Action('animationdirector_particle_library', {
|
||||
name: 'NoN Particle Library', icon: 'grain', category: 'animation',
|
||||
description: 'Browse NoN and asset particle IDs and add particle cues to the timeline.',
|
||||
click: showParticleLibrary, condition: () => !Blockbench.isWeb,
|
||||
});
|
||||
Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener);
|
||||
figuraSoundCompatEnabled = true;
|
||||
figuraSoundPlayListener = beginFiguraSoundPlayback;
|
||||
@@ -6585,6 +6737,7 @@ Features without a reference need no additional addon. Whole-animation import an
|
||||
blockRequirementAction,
|
||||
propAction,
|
||||
soundLibraryAction,
|
||||
particleLibraryAction,
|
||||
'_',
|
||||
createAnimationDefinitionAction,
|
||||
exportConjoinedAction,
|
||||
@@ -6611,6 +6764,8 @@ Features without a reference need no additional addon. Whole-animation import an
|
||||
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();
|
||||
if (typeof document !== 'undefined') document.getElementById('animationdirector_particle_library_button')?.remove();
|
||||
particleLibraryAction?.delete(); particleLibraryDialog?.delete(); particleLibrary = undefined;
|
||||
clearSoundAudio(); soundLibrary = undefined;
|
||||
clearPropMeshes(); propSources.clear();
|
||||
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();
|
||||
|
||||
@@ -8,7 +8,7 @@ Blockbench tools for creating multi-actor animations and animation definitions f
|
||||
- Supported baseline: Blockbench **5.2.1 or newer**.
|
||||
- Enable the **GeckoLib** format plugin to create/import multi-actor animation projects.
|
||||
- **Create JEM Model** requires **CEM Template Loader**. Its preset loader may require an internet connection.
|
||||
- Desktop Blockbench is required for automatic import, pack folders, item props, JEM transfer, and the NoN sound library. These actions are hidden in browser Blockbench; manual JSON import/export remains available.
|
||||
- Desktop Blockbench is required for automatic import, pack folders, item props, JEM transfer, and the NoN sound and particle libraries. These actions are hidden in browser Blockbench; manual JSON import/export remains available.
|
||||
- **Asset Browser** is optional. AnimationDirector can use its downloaded Minecraft asset cache, installed Minecraft client archives, or manually selected assets. It does not download vanilla textures itself.
|
||||
|
||||
## Workflow
|
||||
@@ -23,6 +23,7 @@ Blockbench tools for creating multi-actor animations and animation definitions f
|
||||
## Preview Tools
|
||||
|
||||
- **NoN Sound Library** auditions cues and assigns local sound previews to imported/created sound keyframes. These preview files are not exported as animation cues.
|
||||
- **NoN Particle Library** appears on particle keyframes and in the animation tools menu. Search/filter NoN particles and IDs discovered from remembered pack folders, local Minecraft assets, or a selected JAR/ZIP/assets folder. Apply to selected particle keyframes or insert at the playhead, then set the locator in the keyframe panel. There are no particle previews yet. Asset discovery is not a runtime registry check; parameterized particles need a NoN addon resolver unless NoN already handles them (such as its liquid particles).
|
||||
- **Autoplay Stages** advances non-looping clips to the next physical stage of the same animation ID. It takes priority over Blockbench's global loop toggle without changing that preference. Looping clips and custom timeline ranges remain unchanged.
|
||||
- The timeline pose buttons copy the preceding stage's final pose to the current start, or copy keyed channels from the current start to its end.
|
||||
- Mixed texture-size UV normalization is applied only to GeckoLib projects. Other formats retain their native UV sizing.
|
||||
|
||||
@@ -137,7 +137,7 @@ test('desktop-only tools are visible in desktop Blockbench and hidden in browser
|
||||
const h = harness(); h.context.registration.onload();
|
||||
const actions = h.menus.flatMap((menu) => menu.children).filter((action) => action && typeof action !== 'string');
|
||||
const ids = ['animationdirector_automatic_import', 'animationdirector_pack_folders', 'animationdirector_item_props',
|
||||
'animationdirector_sound_library', 'animationdirector_create_jem_model'];
|
||||
'animationdirector_sound_library', 'animationdirector_particle_library', 'animationdirector_create_jem_model'];
|
||||
for (const id of ids) {
|
||||
const action = actions.find((entry) => entry.id === id);
|
||||
assert.ok(action, `${id} is registered`);
|
||||
@@ -155,7 +155,7 @@ test('animation menu orders import and creation actions and has no deprecated sp
|
||||
assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [
|
||||
'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||
'_', 'Actor Preferences', 'Replace Actor Model',
|
||||
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library',
|
||||
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'NoN Particle Library',
|
||||
'_', 'Animation Definition', 'Export Animations',
|
||||
]);
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||
|
||||
@@ -11,7 +11,7 @@ function harness() {
|
||||
.replace(' Plugin.register(PLUGIN_ID, {', `
|
||||
globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds,
|
||||
applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY,
|
||||
installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper };
|
||||
installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper, refreshSoundLibraryPanel };
|
||||
Plugin.register(PLUGIN_ID, {`);
|
||||
class EffectAnimator {
|
||||
constructor(animation) { this.animation = animation; this.sound = []; }
|
||||
@@ -69,6 +69,33 @@ function assets(t, json, files = {}) {
|
||||
return root;
|
||||
}
|
||||
|
||||
test('keyframe sound library button only appears for sound selections', () => {
|
||||
const h = harness(), elements = new Map();
|
||||
const id = 'animationdirector_sound_library_button';
|
||||
elements.set('panel_keyframe', { appendChild(button) { elements.set(button.id, button); } });
|
||||
h.context.Blockbench = { isWeb: false };
|
||||
h.context.document = {
|
||||
getElementById(id) { return elements.get(id); },
|
||||
createElement() { return { style: {}, addEventListener() {}, remove() { elements.delete(this.id); } }; },
|
||||
};
|
||||
for (const channel of ['particle', 'rotation', null, 'sound', 'particle', 'sound']) {
|
||||
h.context.Timeline.selected = channel ? [{ channel }] : [];
|
||||
h.refreshSoundLibraryPanel();
|
||||
assert.equal(elements.has(id), channel === 'sound', `Button visibility for ${channel}`);
|
||||
}
|
||||
h.context.Timeline.selected.push({ channel: 'particle' });
|
||||
h.refreshSoundLibraryPanel();
|
||||
assert.equal(elements.has(id), false, 'Mixed selections do not show sound-only controls');
|
||||
h.context.Timeline.selected = [{ channel: 'sound' }];
|
||||
h.refreshSoundLibraryPanel();
|
||||
const first = elements.get(id);
|
||||
h.refreshSoundLibraryPanel();
|
||||
assert.equal(elements.get(id), first, 'Refreshing does not duplicate the button');
|
||||
h.context.Blockbench.isWeb = true;
|
||||
h.refreshSoundLibraryPanel();
|
||||
assert.equal(elements.has(id), false, 'Desktop-only controls remain hidden in browsers');
|
||||
});
|
||||
|
||||
test('sound library reads the real NoN sound registry, lazily maps semantic cues and caches audio', () => {
|
||||
const h = harness();
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources'));
|
||||
|
||||
Reference in New Issue
Block a user