particle library

This commit is contained in:
2026-09-29 16:07:35 +02:00
parent 41c9cda88f
commit 13b3c9dd77
4 changed files with 189 additions and 6 deletions
+157 -2
View File
@@ -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();