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();
|
||||
|
||||
Reference in New Issue
Block a user