particle library
This commit is contained in:
+96
-12
@@ -6162,7 +6162,7 @@
|
||||
}
|
||||
|
||||
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 paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : []),
|
||||
...discoverPropSources(io).map((source) => source.file)]);
|
||||
@@ -6173,16 +6173,80 @@
|
||||
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);
|
||||
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}`); }
|
||||
}
|
||||
particleLibrary = { ids: [...ids].sort(), warnings };
|
||||
particleLibrary = { ids: [...ids].sort(), warnings, definitions, paths: paths.slice(0, 64),
|
||||
thumbnails: new Map(), thumbnailBytes: 0 };
|
||||
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 (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;
|
||||
if (!animation) throw new Error('Select an animation first.');
|
||||
const selected = (Timeline.selected || []).filter((frame) => frame.channel === 'particle' && frame.animator?.animation === animation);
|
||||
@@ -6192,6 +6256,7 @@
|
||||
const point = frame.data_points[0];
|
||||
if (point.effect !== id) point.file = '';
|
||||
point.effect = id;
|
||||
if (locator !== null) point.locator = locator;
|
||||
}
|
||||
Undo.finishEdit('Apply particle from library');
|
||||
} else {
|
||||
@@ -6199,9 +6264,10 @@
|
||||
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 }] });
|
||||
const point = { effect: id, ...(locator !== null ? { locator } : {}) };
|
||||
if (frame) frame.extend({ data_points: [...frame.data_points.map((point) => point.getUndoCopy()), point] });
|
||||
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);
|
||||
}
|
||||
effects.select(); frame.select();
|
||||
@@ -6215,22 +6281,34 @@
|
||||
try { loadParticleLibrary(); }
|
||||
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 = 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: '' }; },
|
||||
ids: particleLibrary.ids, locators, locator: defaultLocator, thumbnailRevision: 0,
|
||||
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); },
|
||||
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; } },
|
||||
methods: {
|
||||
reload() {
|
||||
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; }
|
||||
},
|
||||
folder() {
|
||||
@@ -6243,18 +6321,24 @@
|
||||
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">
|
||||
<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 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 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>
|
||||
<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>
|
||||
</div>`,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user