From e7bb16df64045443e62f9c166e4c7cad3da5f2b1 Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Tue, 29 Sep 2026 16:36:08 +0200 Subject: [PATCH] particle library --- AnimationDirector.js | 166 +++++++++++++++++++++++++++++-- README.md | 3 +- test/block-requirements.test.js | 3 +- test/previous-stage-pose.test.js | 30 +++++- 4 files changed, 187 insertions(+), 15 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index cb173bb..ad595f6 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -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: `
Includes NoN particles, remembered Pack Folders, and locally available Minecraft assets. Select a mod JAR to add its particles.
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.
+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.
{{error}}
{{warnings}}