particle library

This commit is contained in:
2026-09-29 16:13:42 +02:00
parent 13b3c9dd77
commit 2780f8aebb
2 changed files with 97 additions and 13 deletions
+96 -12
View File
@@ -6162,7 +6162,7 @@
} }
function loadParticleLibrary(io = desktopFiles()) { function loadParticleLibrary(io = desktopFiles()) {
const ids = new Set(NON_PARTICLE_IDS), warnings = []; const ids = new Set(NON_PARTICLE_IDS), warnings = [], definitions = new Map();
const selected = localStorage.getItem(PARTICLE_SOURCE_KEY) || localStorage.getItem(SOUND_SOURCE_KEY); const selected = localStorage.getItem(PARTICLE_SOURCE_KEY) || localStorage.getItem(SOUND_SOURCE_KEY);
const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : []), const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : []),
...discoverPropSources(io).map((source) => source.file)]); ...discoverPropSources(io).map((source) => source.file)]);
@@ -6173,16 +6173,80 @@
for (const name of particleJsonPaths(source, io)) { for (const name of particleJsonPaths(source, io)) {
const match = name.match(/^assets\/([^/]+)\/particles\/(.+)\.json$/); const match = name.match(/^assets\/([^/]+)\/particles\/(.+)\.json$/);
const id = match && `${match[1]}:${match[2]}`; const id = match && `${match[1]}:${match[2]}`;
if (id && importResourceId(id) && ids.size < 10000) ids.add(id); if (id && importResourceId(id) && ids.size < 10000) {
ids.add(id);
if (!definitions.has(id)) definitions.set(id, { file, name });
}
} }
} catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); } } catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); }
} }
particleLibrary = { ids: [...ids].sort(), warnings }; particleLibrary = { ids: [...ids].sort(), warnings, definitions, paths: paths.slice(0, 64),
thumbnails: new Map(), thumbnailBytes: 0 };
return particleLibrary; return particleLibrary;
} }
function applyLibraryParticle(id, insert = false) { function particleThumbnailRows(ids, library = particleLibrary, io = null) {
const pending = new Map(), groups = new Map();
if (!library?.definitions) return ids.map((id) => ({ id, thumbnail: '' }));
for (const id of ids) {
if (library.thumbnails.has(id)) continue;
library.thumbnails.set(id, '');
const definition = library.definitions.get(id);
if (!definition || library.thumbnailBytes >= 8 * 1024 * 1024) continue;
if (!groups.has(definition.file)) groups.set(definition.file, []);
groups.get(definition.file).push({ id, name: definition.name });
}
const readSprite = (source, id, name) => {
try {
const bytes = source.read(name);
if (!bytes) return false;
// Keep thumbnails bounded and use PNG data URLs rather than exposing local file paths.
if (bytes.length >= 24 && bytes.length <= 256 * 1024
&& bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
&& bytes.readUInt32BE(16) > 0 && bytes.readUInt32BE(16) <= 1024
&& bytes.readUInt32BE(20) > 0 && bytes.readUInt32BE(20) <= 1024
&& library.thumbnailBytes + bytes.length <= 8 * 1024 * 1024) {
library.thumbnails.set(id, `data:image/png;base64,${bytes.toString('base64')}`);
library.thumbnailBytes += bytes.length;
}
return true;
} catch (_) { return false; }
};
// Batch the visible page by source so cross-pack sprites do not repeatedly reopen large JARs.
for (const [file, entries] of groups) {
try {
io ||= desktopFiles();
const source = openPropSource(file, io);
for (const { id, name } of entries) {
try {
const json = propJson(source, name);
const sprite = Array.isArray(json?.textures) ? json.textures[0] : null;
if (typeof sprite !== 'string') continue;
const texture = propResourcePath(sprite, 'textures/particle', '.png');
if (!readSprite(source, id, texture)) pending.set(id, texture);
} catch (_) { /* Missing or malformed assets do not prevent selecting a particle. */ }
}
} catch (_) { /* Unavailable sources retain a placeholder thumbnail. */ }
}
for (const file of library.paths) {
if (!pending.size) break;
try {
const source = openPropSource(file, io);
for (const [id, name] of pending) if (readSprite(source, id, name)) pending.delete(id);
} catch (_) { /* Try the remaining asset sources. */ }
}
return ids.map((id) => ({ id, thumbnail: library.thumbnails.get(id) || '' }));
}
function availableParticleLocators() {
const locators = typeof Locator !== 'undefined' ? Locator.all
: typeof Outliner !== 'undefined' ? Outliner.elements?.filter((element) => element.type === 'locator') : [];
return uniqueTrimmedStrings((locators || []).map((locator) => locator.name)).sort();
}
function applyLibraryParticle(id, insert = false, locator = null) {
if (!importResourceId(id)) throw new Error('Invalid particle ID.'); if (!importResourceId(id)) throw new Error('Invalid particle ID.');
if (locator !== null && !availableParticleLocators().includes(locator)) throw new Error('The selected locator is no longer available in this project.');
const animation = typeof Animation === 'undefined' ? null : Animation.selected; const animation = typeof Animation === 'undefined' ? null : Animation.selected;
if (!animation) throw new Error('Select an animation first.'); if (!animation) throw new Error('Select an animation first.');
const selected = (Timeline.selected || []).filter((frame) => frame.channel === 'particle' && frame.animator?.animation === animation); const selected = (Timeline.selected || []).filter((frame) => frame.channel === 'particle' && frame.animator?.animation === animation);
@@ -6192,6 +6256,7 @@
const point = frame.data_points[0]; const point = frame.data_points[0];
if (point.effect !== id) point.file = ''; if (point.effect !== id) point.file = '';
point.effect = id; point.effect = id;
if (locator !== null) point.locator = locator;
} }
Undo.finishEdit('Apply particle from library'); Undo.finishEdit('Apply particle from library');
} else { } else {
@@ -6199,9 +6264,10 @@
const keyframes = effects.particle.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001); const keyframes = effects.particle.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001);
Undo.initEdit({ keyframes }); Undo.initEdit({ keyframes });
let frame = keyframes[0]; let frame = keyframes[0];
if (frame) frame.extend({ data_points: [...frame.data_points.map((point) => point.getUndoCopy()), { effect: id }] }); const point = { effect: id, ...(locator !== null ? { locator } : {}) };
if (frame) frame.extend({ data_points: [...frame.data_points.map((point) => point.getUndoCopy()), point] });
else { else {
frame = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'particle', false, false); frame = effects.createKeyframe({ data_points: [point] }, Timeline.time, 'particle', false, false);
keyframes.push(frame); keyframes.push(frame);
} }
effects.select(); frame.select(); effects.select(); frame.select();
@@ -6215,22 +6281,34 @@
try { loadParticleLibrary(); } try { loadParticleLibrary(); }
catch (error) { particleLibrary = { ids: [...NON_PARTICLE_IDS].sort(), warnings: [error.message] }; } catch (error) { particleLibrary = { ids: [...NON_PARTICLE_IDS].sort(), warnings: [error.message] }; }
} }
const locators = availableParticleLocators();
const selectedFrames = (typeof Timeline === 'undefined' ? [] : Timeline.selected || [])
.filter((frame) => frame.channel === 'particle' && frame.animator?.animation === Animation.selected);
const currentLocators = uniqueTrimmedStrings(selectedFrames.map((frame) => frame.data_points[0]?.locator || ''));
const defaultLocator = selectedFrames.length && currentLocators.length === 1 && locators.includes(currentLocators[0])
&& selectedFrames.every((frame) => frame.data_points[0]?.locator === currentLocators[0])
? currentLocators[0] : selectedFrames.length ? '' : locators[0] || '';
particleLibraryDialog?.delete(); particleLibraryDialog?.delete();
particleLibraryDialog = new Dialog({ id: 'animationdirector_particle_library', title: 'NoN Particle Library', width: 700, particleLibraryDialog = new Dialog({ id: 'animationdirector_particle_library', title: 'NoN Particle Library', width: 700,
buttons: ['Close'], component: { buttons: ['Close'], component: {
data() { return { query: '', category: 'all', selected: '', page: 0, data() { return { query: '', category: 'all', selected: '', page: 0,
ids: particleLibrary.ids, warnings: particleLibrary.warnings.join('\n'), error: '' }; }, ids: particleLibrary.ids, locators, locator: defaultLocator, thumbnailRevision: 0,
warnings: particleLibrary.warnings.join('\n'), error: '' }; },
computed: { computed: {
categories() { return [...new Set(this.ids.map((id) => id.split(':')[0]))].sort(); }, 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}:`)) rows() { return this.ids.filter((id) => (this.category === 'all' || id.startsWith(`${this.category}:`))
&& id.toLowerCase().includes(this.query.toLowerCase())); }, && id.toLowerCase().includes(this.query.toLowerCase())); },
visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); }, visibleRows() {
// Reloading replaces the asset cache, even if the list of IDs stays unchanged.
void this.thumbnailRevision;
return particleThumbnailRows(this.rows.slice(this.page * 100, (this.page + 1) * 100));
},
}, },
watch: { query() { this.page = 0; }, category() { this.page = 0; } }, watch: { query() { this.page = 0; }, category() { this.page = 0; } },
methods: { methods: {
reload() { reload() {
try { loadParticleLibrary(); this.ids = particleLibrary.ids; this.warnings = particleLibrary.warnings.join('\n'); try { loadParticleLibrary(); this.ids = particleLibrary.ids; this.warnings = particleLibrary.warnings.join('\n');
this.page = 0; this.error = ''; } this.page = 0; this.thumbnailRevision++; this.error = ''; }
catch (error) { this.error = error.message; } catch (error) { this.error = error.message; }
}, },
folder() { folder() {
@@ -6243,18 +6321,24 @@
if (files?.[0]?.path) { localStorage.setItem(PARTICLE_SOURCE_KEY, files[0].path); this.reload(); } 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; } }, apply(insert) { try { applyLibraryParticle(this.selected, insert, this.locator || null); this.error = ''; } catch (error) { this.error = error.message; } },
}, },
template: `<div style="padding:12px"> 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> <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> <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 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 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 v-for="row in visibleRows" :key="row.id" @click="selected=row.id" :style="{background:selected===row.id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer',display:'flex',alignItems:'center',gap:'10px'}">
<span style="width:28px;height:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--color-back);border:1px solid var(--color-border)">
<img v-if="row.thumbnail" :src="row.thumbnail" alt="Particle sprite" @error="$event.target.style.display='none'" style="width:24px;height:24px;object-fit:contain;image-rendering:pixelated">
<span v-else style="opacity:.35">?</span>
</span><span>{{row.id}}</span>
</div>
</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"><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:8px;margin-top:12px"><label for="animationdirector_particle_locator">Locator</label><select id="animationdirector_particle_locator" v-model="locator" style="flex:1"><option value="">Keep existing / unset</option><option v-for="name in locators" :key="name" :value="name">{{name}}</option></select></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> <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 class="subtle">Thumbnails show the first sprite, not particle motion or in-game tint. 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> <p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
</div>`, </div>`,
}, },
+1 -1
View File
@@ -23,7 +23,7 @@ Blockbench tools for creating multi-actor animations and animation definitions f
## Preview Tools ## 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 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). - **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. Choose an available project locator, then apply to selected particle keyframes or insert at the playhead. List thumbnails show the first available sprite without motion or in-game tint; particles without a resolvable sprite show a placeholder. 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. - **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. - 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. - Mixed texture-size UV normalization is applied only to GeckoLib projects. Other formats retain their native UV sizing.