particle library

This commit is contained in:
2026-09-29 16:36:08 +02:00
parent 2780f8aebb
commit e7bb16df64
4 changed files with 187 additions and 15 deletions
+156 -10
View File
@@ -24,7 +24,7 @@
let loopPoseAction;
let autoplayStagesAction, autoplayLoopOriginal, autoplayLoopWrapper;
let autoplayTransitioning = false;
const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation'];
const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'finish_edit', 'add_animation', 'remove_animation'];
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
let propAction, propProjectProperty, propDialog, propListener;
@@ -34,7 +34,7 @@
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
const SOUND_SELECTION_BLACKLIST = new Set(['needsofnature:a', 'needsofnature:funkytown']);
const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview';
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection', 'undo', 'redo'];
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection', 'undo', 'redo', 'finish_edit'];
let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition;
let soundLibrary, soundLibraryGeneration = 0;
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
@@ -44,6 +44,13 @@
'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 PARTICLE_PREVIEW_PROPERTY = 'animationdirector_particle_preview';
const PARTICLE_PREVIEW_PREFIX = 'animationdirector-particle:';
const PARTICLE_PREVIEWS_KEY = 'animationdirector_particle_previews';
const PARTICLE_PREVIEW_KINDS = { 'needsofnature:liquid_particle': 'liquid', 'needsofnature:liquid_particle_falling': 'falling',
'needsofnature:smallheart': 'heart', 'needsofnature:ripped_fabric': 'fabric' };
let particlePreviewProperty, particlePreviewToggle, particlePreviewsEnabled = true;
const particlePreviewEffects = new Map(), particlePreviewPoints = new Set();
const figuraSoundPatches = [], figuraSoundMedia = new Set();
let figuraSoundCompatEnabled = true, figuraSoundPlayListener;
const SYSTEM_TAG_OPTIONS = {
@@ -2958,9 +2965,10 @@
const { reason } = previousStagePoseSource();
const description = reason || 'Copy the final transforms of all actors from the previous stage into time 0. Existing time-0 transforms are replaced.';
previousStagePoseAction.description = description;
for (const node of previousStagePoseAction.nodes || []) {
for (const node of new Set([previousStagePoseAction.node, ...(previousStagePoseAction.nodes || [])].filter(Boolean))) {
node.classList.toggle('disabled', !!reason);
node.setAttribute('aria-disabled', String(!!reason));
if (node.style) { node.style.opacity = reason ? '0.35' : ''; node.style.filter = reason ? 'grayscale(1)' : ''; }
const label = node.querySelector('.tooltip_description');
if (label) label.textContent = description;
}
@@ -2971,6 +2979,7 @@
const animation = Animation.selected;
if (!animation) return 'Select an animation clip first.';
if (!Number.isFinite(animation.length) || animation.length <= 0) return 'Set a positive animation clip length first.';
if (animation.loop !== 'loop') return 'Set this animation to Loop before copying its first pose to the end.';
return null;
}
@@ -2979,9 +2988,10 @@
const reason = loopPoseUnavailableReason();
const description = reason || 'Copy the first pose of all actors to the clip endpoint for looping. Existing endpoint transforms are replaced.';
loopPoseAction.description = description;
for (const node of loopPoseAction.nodes || []) {
for (const node of new Set([loopPoseAction.node, ...(loopPoseAction.nodes || [])].filter(Boolean))) {
node.classList.toggle('disabled', !!reason);
node.setAttribute('aria-disabled', String(!!reason));
if (node.style) { node.style.opacity = reason ? '0.35' : ''; node.style.filter = reason ? 'grayscale(1)' : ''; }
const label = node.querySelector('.tooltip_description');
if (label) label.textContent = description;
}
@@ -3038,13 +3048,19 @@
Blockbench.showQuickMessage(`Copied actor pose to time ${targetTime}.`, 2500);
}
function cleanSoundPreviewFields(clip) {
function cleanEffectPreviewFields(clip) {
for (const cues of Object.values(clip?.sound_effects || {})) {
for (const cue of Array.isArray(cues) ? cues : [cues]) {
if (!cue || typeof cue !== 'object') continue;
delete cue.file; delete cue[SOUND_PREVIEW_PROPERTY];
}
}
for (const cues of Object.values(clip?.particle_effects || {})) {
for (const cue of Array.isArray(cues) ? cues : [cues]) {
if (!cue || typeof cue !== 'object') continue;
delete cue.file; delete cue[PARTICLE_PREVIEW_PROPERTY];
}
}
return clip;
}
@@ -3056,7 +3072,7 @@
const compiled = animation.compileBedrockAnimation() || {};
if (!compiled.animations) {
return { formatVersion: '1.8.0', clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(compiled))) };
return { formatVersion: '1.8.0', clip: cleanEffectPreviewFields(stripBlockRequirementGuideBones(clone(compiled))) };
}
let clip = compiled.animations[stage.name];
@@ -3069,7 +3085,7 @@
}
return {
formatVersion: compiled.format_version || '1.8.0',
clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(clip))),
clip: cleanEffectPreviewFields(stripBlockRequirementGuideBones(clone(clip))),
};
}
@@ -3406,6 +3422,7 @@
saveAnimdef(group.id, definition);
if (undo) Undo.finishEdit('Save animation definition');
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
}
function exportAnimationDefinitionStub(group, definition, actorMetadata) {
@@ -3561,6 +3578,7 @@
cancelBlockRequirementUpdate();
rebuildBlockRequirementGuides(group.id, entry, definition, true, props.entries);
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
if (props.warnings.length) Blockbench.showQuickMessage(`Item props: ${props.warnings.join(' ')}`, 6000);
void refreshPropPreviews(true);
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id);
@@ -4569,6 +4587,7 @@
};
Animator.loadFile(fakeFile, imported.stageNames);
mapAnimationSounds(Animation.all);
mapAnimationParticles(Animation.all);
Blockbench.showQuickMessage(
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
2500
@@ -5797,6 +5816,7 @@
setProjectAnimdefAnimators(plan.definition.animators || []);
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
mapAnimationSounds(Animation.all);
mapAnimationParticles(Animation.all);
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries);
Canvas.updateAll();
@@ -6162,6 +6182,7 @@
}
function loadParticleLibrary(io = desktopFiles()) {
clearParticlePreviews();
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] : []),
@@ -6180,11 +6201,16 @@
}
} catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); }
}
particleLibrary = { ids: [...ids].sort(), warnings, definitions, paths: paths.slice(0, 64),
particleLibrary = { ids: sortParticleLibraryIds(ids), warnings, definitions, paths: paths.slice(0, 64),
thumbnails: new Map(), thumbnailBytes: 0 };
return particleLibrary;
}
function sortParticleLibraryIds(ids) {
return [...ids].sort((a, b) => Number(b.startsWith('needsofnature:')) - Number(a.startsWith('needsofnature:'))
|| (a < b ? -1 : a > b ? 1 : 0));
}
function particleThumbnailRows(ids, library = particleLibrary, io = null) {
const pending = new Map(), groups = new Map();
if (!library?.definitions) return ids.map((id) => ({ id, thumbnail: '' }));
@@ -6244,6 +6270,106 @@
return uniqueTrimmedStrings((locators || []).map((locator) => locator.name)).sort();
}
function buildParticlePreview(id, texture) {
const kind = PARTICLE_PREVIEW_KINDS[id];
if (!kind || !texture) return null;
const bytes = Buffer.from(texture.split(',')[1], 'base64');
const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20);
const size = kind === 'heart' ? 0.185 : kind === 'fabric' ? 0.19 : 0.12;
const lifetime = kind === 'heart' ? 0.8 : kind === 'fabric' ? 6 : 4;
const uv = { texture_width: width, texture_height: height, uv: [0, 0], uv_size: [width, height] };
if (kind === 'fabric' && height % 5 === 0) {
delete uv.uv; delete uv.uv_size;
uv.flipbook = { base_UV: [0, 0], size_UV: [width, height / 5], step_UV: [0, height / 5],
frames_per_second: 5, max_frame: 5, stretch_to_lifetime: false, loop: true };
}
const components = {
'minecraft:emitter_rate_instant': { num_particles: 1 },
'minecraft:emitter_lifetime_once': { active_time: lifetime },
'minecraft:emitter_local_space': { position: false, rotation: false, velocity: false },
'minecraft:emitter_shape_point': { offset: [0, -1 / 16, 0], direction: [0, 1, 0] },
'minecraft:particle_lifetime_expression': { max_lifetime: lifetime },
'minecraft:particle_initial_speed': kind === 'heart' ? 2 : 0,
'minecraft:particle_motion_dynamic': { linear_acceleration: [0, kind === 'falling' ? -2.4 : kind === 'fabric' ? -0.16 : 0, 0],
linear_drag_coefficient: kind === 'heart' ? 3 : kind === 'fabric' ? 4 : 0.4 },
'minecraft:particle_appearance_billboard': { size: [size, size], facing_camera_mode: 'rotate_xyz', uv },
};
if (kind === 'liquid') {
delete components['minecraft:particle_motion_dynamic'];
components['minecraft:particle_motion_parametric'] = { relative_position: [0,
'v.particle_age < 1 ? 0 : -1.2 * (v.particle_age - 1) * (v.particle_age - 1)', 0] };
}
return { format_version: '1.10.0', particle_effect: {
description: { identifier: id, basic_render_parameters: { material: 'particles_alpha', texture: 'editor_preview' } }, components,
} };
}
function clearParticlePreviews() {
for (const point of particlePreviewPoints) {
if (point.file === point[PARTICLE_PREVIEW_PROPERTY] || point.file?.startsWith(PARTICLE_PREVIEW_PREFIX)) point.file = '';
delete point[PARTICLE_PREVIEW_PROPERTY];
}
particlePreviewPoints.clear();
for (const [key, effect] of particlePreviewEffects) {
for (const emitter of Object.values(effect.emitters)) emitter.delete();
effect.config.texture?.dispose();
if (typeof Animator !== 'undefined' && Animator.particle_effects?.[key] === effect) delete Animator.particle_effects[key];
}
particlePreviewEffects.clear();
}
function mapAnimationParticles(animations) {
if (typeof Animator === 'undefined' || !Animator.particle_effects || typeof Wintersky === 'undefined'
|| !Wintersky.Config || typeof WinterskyScene === 'undefined') return;
const points = [];
const locators = new Set(availableParticleLocators());
for (const animation of animations.filter(Boolean)) {
for (const frame of animation.animators?.effects?.particle || []) {
for (const point of frame.data_points || []) {
const generated = point.file === point[PARTICLE_PREVIEW_PROPERTY] || point.file?.startsWith(PARTICLE_PREVIEW_PREFIX);
if (point.file && !generated) continue;
if (!particlePreviewsEnabled || (typeof Format !== 'undefined' && Format.id !== 'geckolib_model')
|| !locators.has(point.locator) || !Object.hasOwn(PARTICLE_PREVIEW_KINDS, point.effect)) {
if (generated) point.file = '';
continue;
}
points.push(point);
}
}
}
if (!points.length) return;
if (!particleLibrary) {
try { loadParticleLibrary(); } catch (_) { return; }
}
const ids = uniqueTrimmedStrings(points.map((point) => point.effect));
for (const { id, thumbnail } of particleThumbnailRows(ids)) {
const key = `${PARTICLE_PREVIEW_PREFIX}${id}`;
if (particlePreviewEffects.has(key) && Animator.particle_effects[key] === particlePreviewEffects.get(key)) continue;
const json = buildParticlePreview(id, thumbnail);
if (!json) continue;
// Register in memory: loadParticleEmitter would install a filesystem watcher on a virtual file.
const config = new Wintersky.Config(WinterskyScene, { preview_texture: thumbnail });
config.setFromJSON(json);
const effect = { config, emitters: {} };
Animator.particle_effects[key] = effect; particlePreviewEffects.set(key, effect);
}
for (const point of points) {
const key = `${PARTICLE_PREVIEW_PREFIX}${point.effect}`;
point.file = particlePreviewEffects.has(key) ? key : '';
point[PARTICLE_PREVIEW_PROPERTY] = point.file;
particlePreviewPoints.add(point);
}
}
function setParticlePreviewsEnabled(enabled) {
particlePreviewsEnabled = !!enabled;
localStorage.setItem(PARTICLE_PREVIEWS_KEY, String(particlePreviewsEnabled));
if (particlePreviewToggle) { particlePreviewToggle.value = particlePreviewsEnabled; particlePreviewToggle.updateEnabledState?.(); }
clearParticlePreviews();
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
if (typeof Animator !== 'undefined') Animator.preview?.();
}
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.');
@@ -6273,13 +6399,14 @@
effects.select(); frame.select();
Undo.finishEdit('Insert particle from library', { keyframes });
}
mapAnimationParticles([animation]);
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
}
function showParticleLibrary() {
if (!particleLibrary) {
try { loadParticleLibrary(); }
catch (error) { particleLibrary = { ids: [...NON_PARTICLE_IDS].sort(), warnings: [error.message] }; }
catch (error) { particleLibrary = { ids: sortParticleLibraryIds(NON_PARTICLE_IDS), warnings: [error.message] }; }
}
const locators = availableParticleLocators();
const selectedFrames = (typeof Timeline === 'undefined' ? [] : Timeline.selected || [])
@@ -6293,6 +6420,7 @@
buttons: ['Close'], component: {
data() { return { query: '', category: 'all', selected: '', page: 0,
ids: particleLibrary.ids, locators, locator: defaultLocator, thumbnailRevision: 0,
previews: particlePreviewsEnabled,
warnings: particleLibrary.warnings.join('\n'), error: '' }; },
computed: {
categories() { return [...new Set(this.ids.map((id) => id.split(':')[0]))].sort(); },
@@ -6308,7 +6436,8 @@
methods: {
reload() {
try { loadParticleLibrary(); this.ids = particleLibrary.ids; this.warnings = particleLibrary.warnings.join('\n');
this.page = 0; this.thumbnailRevision++; this.error = ''; }
this.page = 0; this.thumbnailRevision++; this.error = '';
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all); }
catch (error) { this.error = error.message; }
},
folder() {
@@ -6322,9 +6451,11 @@
});
},
apply(insert) { try { applyLibraryParticle(this.selected, insert, this.locator || null); this.error = ''; } catch (error) { this.error = error.message; } },
togglePreviews() { setParticlePreviewsEnabled(this.previews); },
},
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><label><input type="checkbox" v-model="previews" @change="togglePreviews"> Particle Previews</label></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 style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)">
@@ -6339,6 +6470,7 @@
<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">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 class="subtle">Timeline previews support NoN liquid, falling liquid, small hearts, and ripped fabric. Select NoN assets to load their textures. Motion is approximate; world collisions, liquid color and gameplay are not simulated.</p>
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
</div>`,
},
@@ -6473,6 +6605,7 @@
installFiguraSoundCompat();
if (typeof Animation === 'undefined') return;
mapAnimationSounds(Animation.all);
mapAnimationParticles(Animation.all);
for (const animation of Animation.all) {
const effects = animation.animators?.effects;
if (effects) for (const patch of figuraSoundPatches) {
@@ -6517,6 +6650,7 @@
}
}
mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]);
mapAnimationParticles(typeof Animation === 'undefined' ? [] : [Animation.selected]);
}
Plugin.register(PLUGIN_ID, {
@@ -6602,7 +6736,16 @@ Features without a reference need no additional addon. Whole-animation import an
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
exposed: false, condition: (point) => point.keyframe.channel === 'sound',
});
particlePreviewProperty = new Property(KeyframeDataPoint, 'string', PARTICLE_PREVIEW_PROPERTY, {
exposed: false, condition: (point) => point.keyframe.channel === 'particle',
});
}
particlePreviewsEnabled = localStorage.getItem(PARTICLE_PREVIEWS_KEY) !== 'false';
particlePreviewToggle = new Toggle('animationdirector_particle_previews', {
name: 'Particle Previews', icon: 'grain', category: 'animation', default: particlePreviewsEnabled,
onChange: setParticlePreviewsEnabled, condition: () => !Blockbench.isWeb,
});
Blockbench.on('close_project', clearParticlePreviews);
soundLibraryAction = new Action('animationdirector_sound_library', {
name: 'NoN Sound Library', icon: 'library_music', category: 'animation',
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
@@ -6822,6 +6965,7 @@ Features without a reference need no additional addon. Whole-animation import an
propAction,
soundLibraryAction,
particleLibraryAction,
particlePreviewToggle,
'_',
createAnimationDefinitionAction,
exportConjoinedAction,
@@ -6850,6 +6994,8 @@ Features without a reference need no additional addon. Whole-animation import an
soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete();
if (typeof document !== 'undefined') document.getElementById('animationdirector_particle_library_button')?.remove();
particleLibraryAction?.delete(); particleLibraryDialog?.delete(); particleLibrary = undefined;
Blockbench.removeListener('close_project', clearParticlePreviews);
clearParticlePreviews(); particlePreviewProperty?.delete(); particlePreviewToggle?.delete();
clearSoundAudio(); soundLibrary = undefined;
clearPropMeshes(); propSources.clear();
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();