particle library
This commit is contained in:
+156
-10
@@ -24,7 +24,7 @@
|
|||||||
let loopPoseAction;
|
let loopPoseAction;
|
||||||
let autoplayStagesAction, autoplayLoopOriginal, autoplayLoopWrapper;
|
let autoplayStagesAction, autoplayLoopOriginal, autoplayLoopWrapper;
|
||||||
let autoplayTransitioning = false;
|
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_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
|
||||||
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
|
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
|
||||||
let propAction, propProjectProperty, propDialog, propListener;
|
let propAction, propProjectProperty, propDialog, propListener;
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
|
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
|
||||||
const SOUND_SELECTION_BLACKLIST = new Set(['needsofnature:a', 'needsofnature:funkytown']);
|
const SOUND_SELECTION_BLACKLIST = new Set(['needsofnature:a', 'needsofnature:funkytown']);
|
||||||
const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview';
|
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 soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition;
|
||||||
let soundLibrary, soundLibraryGeneration = 0;
|
let soundLibrary, soundLibraryGeneration = 0;
|
||||||
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
|
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
|
||||||
@@ -44,6 +44,13 @@
|
|||||||
'liquid_particle_water', 'smallheart', 'ripped_fabric', 'spray_splash', 'spray_surface_drip',
|
'liquid_particle_water', 'smallheart', 'ripped_fabric', 'spray_splash', 'spray_surface_drip',
|
||||||
'spray_surface_viscous', 'spray_surface_liquid'].map((name) => `needsofnature:${name}`);
|
'spray_surface_viscous', 'spray_surface_liquid'].map((name) => `needsofnature:${name}`);
|
||||||
let particleLibraryAction, particleLibraryDialog, particleLibrary;
|
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();
|
const figuraSoundPatches = [], figuraSoundMedia = new Set();
|
||||||
let figuraSoundCompatEnabled = true, figuraSoundPlayListener;
|
let figuraSoundCompatEnabled = true, figuraSoundPlayListener;
|
||||||
const SYSTEM_TAG_OPTIONS = {
|
const SYSTEM_TAG_OPTIONS = {
|
||||||
@@ -2958,9 +2965,10 @@
|
|||||||
const { reason } = previousStagePoseSource();
|
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.';
|
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;
|
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.classList.toggle('disabled', !!reason);
|
||||||
node.setAttribute('aria-disabled', String(!!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');
|
const label = node.querySelector('.tooltip_description');
|
||||||
if (label) label.textContent = description;
|
if (label) label.textContent = description;
|
||||||
}
|
}
|
||||||
@@ -2971,6 +2979,7 @@
|
|||||||
const animation = Animation.selected;
|
const animation = Animation.selected;
|
||||||
if (!animation) return 'Select an animation clip first.';
|
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 (!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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2979,9 +2988,10 @@
|
|||||||
const reason = loopPoseUnavailableReason();
|
const reason = loopPoseUnavailableReason();
|
||||||
const description = reason || 'Copy the first pose of all actors to the clip endpoint for looping. Existing endpoint transforms are replaced.';
|
const description = reason || 'Copy the first pose of all actors to the clip endpoint for looping. Existing endpoint transforms are replaced.';
|
||||||
loopPoseAction.description = description;
|
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.classList.toggle('disabled', !!reason);
|
||||||
node.setAttribute('aria-disabled', String(!!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');
|
const label = node.querySelector('.tooltip_description');
|
||||||
if (label) label.textContent = description;
|
if (label) label.textContent = description;
|
||||||
}
|
}
|
||||||
@@ -3038,13 +3048,19 @@
|
|||||||
Blockbench.showQuickMessage(`Copied actor pose to time ${targetTime}.`, 2500);
|
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 cues of Object.values(clip?.sound_effects || {})) {
|
||||||
for (const cue of Array.isArray(cues) ? cues : [cues]) {
|
for (const cue of Array.isArray(cues) ? cues : [cues]) {
|
||||||
if (!cue || typeof cue !== 'object') continue;
|
if (!cue || typeof cue !== 'object') continue;
|
||||||
delete cue.file; delete cue[SOUND_PREVIEW_PROPERTY];
|
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;
|
return clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3056,7 +3072,7 @@
|
|||||||
|
|
||||||
const compiled = animation.compileBedrockAnimation() || {};
|
const compiled = animation.compileBedrockAnimation() || {};
|
||||||
if (!compiled.animations) {
|
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];
|
let clip = compiled.animations[stage.name];
|
||||||
@@ -3069,7 +3085,7 @@
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
formatVersion: compiled.format_version || '1.8.0',
|
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);
|
saveAnimdef(group.id, definition);
|
||||||
if (undo) Undo.finishEdit('Save animation definition');
|
if (undo) Undo.finishEdit('Save animation definition');
|
||||||
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
|
||||||
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportAnimationDefinitionStub(group, definition, actorMetadata) {
|
function exportAnimationDefinitionStub(group, definition, actorMetadata) {
|
||||||
@@ -3561,6 +3578,7 @@
|
|||||||
cancelBlockRequirementUpdate();
|
cancelBlockRequirementUpdate();
|
||||||
rebuildBlockRequirementGuides(group.id, entry, definition, true, props.entries);
|
rebuildBlockRequirementGuides(group.id, entry, definition, true, props.entries);
|
||||||
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
|
||||||
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all);
|
||||||
if (props.warnings.length) Blockbench.showQuickMessage(`Item props: ${props.warnings.join(' ')}`, 6000);
|
if (props.warnings.length) Blockbench.showQuickMessage(`Item props: ${props.warnings.join(' ')}`, 6000);
|
||||||
void refreshPropPreviews(true);
|
void refreshPropPreviews(true);
|
||||||
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id);
|
if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id);
|
||||||
@@ -4569,6 +4587,7 @@
|
|||||||
};
|
};
|
||||||
Animator.loadFile(fakeFile, imported.stageNames);
|
Animator.loadFile(fakeFile, imported.stageNames);
|
||||||
mapAnimationSounds(Animation.all);
|
mapAnimationSounds(Animation.all);
|
||||||
|
mapAnimationParticles(Animation.all);
|
||||||
Blockbench.showQuickMessage(
|
Blockbench.showQuickMessage(
|
||||||
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
|
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
|
||||||
2500
|
2500
|
||||||
@@ -5797,6 +5816,7 @@
|
|||||||
setProjectAnimdefAnimators(plan.definition.animators || []);
|
setProjectAnimdefAnimators(plan.definition.animators || []);
|
||||||
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
|
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
|
||||||
mapAnimationSounds(Animation.all);
|
mapAnimationSounds(Animation.all);
|
||||||
|
mapAnimationParticles(Animation.all);
|
||||||
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
|
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
|
||||||
if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries);
|
if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries);
|
||||||
Canvas.updateAll();
|
Canvas.updateAll();
|
||||||
@@ -6162,6 +6182,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadParticleLibrary(io = desktopFiles()) {
|
function loadParticleLibrary(io = desktopFiles()) {
|
||||||
|
clearParticlePreviews();
|
||||||
const ids = new Set(NON_PARTICLE_IDS), warnings = [], definitions = new Map();
|
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] : []),
|
||||||
@@ -6180,11 +6201,16 @@
|
|||||||
}
|
}
|
||||||
} 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, definitions, paths: paths.slice(0, 64),
|
particleLibrary = { ids: sortParticleLibraryIds(ids), warnings, definitions, paths: paths.slice(0, 64),
|
||||||
thumbnails: new Map(), thumbnailBytes: 0 };
|
thumbnails: new Map(), thumbnailBytes: 0 };
|
||||||
return particleLibrary;
|
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) {
|
function particleThumbnailRows(ids, library = particleLibrary, io = null) {
|
||||||
const pending = new Map(), groups = new Map();
|
const pending = new Map(), groups = new Map();
|
||||||
if (!library?.definitions) return ids.map((id) => ({ id, thumbnail: '' }));
|
if (!library?.definitions) return ids.map((id) => ({ id, thumbnail: '' }));
|
||||||
@@ -6244,6 +6270,106 @@
|
|||||||
return uniqueTrimmedStrings((locators || []).map((locator) => locator.name)).sort();
|
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) {
|
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.');
|
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();
|
effects.select(); frame.select();
|
||||||
Undo.finishEdit('Insert particle from library', { keyframes });
|
Undo.finishEdit('Insert particle from library', { keyframes });
|
||||||
}
|
}
|
||||||
|
mapAnimationParticles([animation]);
|
||||||
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showParticleLibrary() {
|
function showParticleLibrary() {
|
||||||
if (!particleLibrary) {
|
if (!particleLibrary) {
|
||||||
try { loadParticleLibrary(); }
|
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 locators = availableParticleLocators();
|
||||||
const selectedFrames = (typeof Timeline === 'undefined' ? [] : Timeline.selected || [])
|
const selectedFrames = (typeof Timeline === 'undefined' ? [] : Timeline.selected || [])
|
||||||
@@ -6293,6 +6420,7 @@
|
|||||||
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, locators, locator: defaultLocator, thumbnailRevision: 0,
|
ids: particleLibrary.ids, locators, locator: defaultLocator, thumbnailRevision: 0,
|
||||||
|
previews: particlePreviewsEnabled,
|
||||||
warnings: particleLibrary.warnings.join('\n'), error: '' }; },
|
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(); },
|
||||||
@@ -6308,7 +6436,8 @@
|
|||||||
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.thumbnailRevision++; this.error = ''; }
|
this.page = 0; this.thumbnailRevision++; this.error = '';
|
||||||
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : Animation.all); }
|
||||||
catch (error) { this.error = error.message; }
|
catch (error) { this.error = error.message; }
|
||||||
},
|
},
|
||||||
folder() {
|
folder() {
|
||||||
@@ -6322,9 +6451,11 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
apply(insert) { try { applyLibraryParticle(this.selected, insert, this.locator || null); 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; } },
|
||||||
|
togglePreviews() { setParticlePreviewsEnabled(this.previews); },
|
||||||
},
|
},
|
||||||
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><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>
|
<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)">
|
||||||
@@ -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: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">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">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>
|
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
},
|
},
|
||||||
@@ -6473,6 +6605,7 @@
|
|||||||
installFiguraSoundCompat();
|
installFiguraSoundCompat();
|
||||||
if (typeof Animation === 'undefined') return;
|
if (typeof Animation === 'undefined') return;
|
||||||
mapAnimationSounds(Animation.all);
|
mapAnimationSounds(Animation.all);
|
||||||
|
mapAnimationParticles(Animation.all);
|
||||||
for (const animation of Animation.all) {
|
for (const animation of Animation.all) {
|
||||||
const effects = animation.animators?.effects;
|
const effects = animation.animators?.effects;
|
||||||
if (effects) for (const patch of figuraSoundPatches) {
|
if (effects) for (const patch of figuraSoundPatches) {
|
||||||
@@ -6517,6 +6650,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]);
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]);
|
||||||
|
mapAnimationParticles(typeof Animation === 'undefined' ? [] : [Animation.selected]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Plugin.register(PLUGIN_ID, {
|
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, {
|
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
|
||||||
exposed: false, condition: (point) => point.keyframe.channel === 'sound',
|
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', {
|
soundLibraryAction = new Action('animationdirector_sound_library', {
|
||||||
name: 'NoN Sound Library', icon: 'library_music', category: 'animation',
|
name: 'NoN Sound Library', icon: 'library_music', category: 'animation',
|
||||||
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
|
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,
|
propAction,
|
||||||
soundLibraryAction,
|
soundLibraryAction,
|
||||||
particleLibraryAction,
|
particleLibraryAction,
|
||||||
|
particlePreviewToggle,
|
||||||
'_',
|
'_',
|
||||||
createAnimationDefinitionAction,
|
createAnimationDefinitionAction,
|
||||||
exportConjoinedAction,
|
exportConjoinedAction,
|
||||||
@@ -6850,6 +6994,8 @@ Features without a reference need no additional addon. Whole-animation import an
|
|||||||
soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete();
|
soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete();
|
||||||
if (typeof document !== 'undefined') document.getElementById('animationdirector_particle_library_button')?.remove();
|
if (typeof document !== 'undefined') document.getElementById('animationdirector_particle_library_button')?.remove();
|
||||||
particleLibraryAction?.delete(); particleLibraryDialog?.delete(); particleLibrary = undefined;
|
particleLibraryAction?.delete(); particleLibraryDialog?.delete(); particleLibrary = undefined;
|
||||||
|
Blockbench.removeListener('close_project', clearParticlePreviews);
|
||||||
|
clearParticlePreviews(); particlePreviewProperty?.delete(); particlePreviewToggle?.delete();
|
||||||
clearSoundAudio(); soundLibrary = undefined;
|
clearSoundAudio(); soundLibrary = undefined;
|
||||||
clearPropMeshes(); propSources.clear();
|
clearPropMeshes(); propSources.clear();
|
||||||
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();
|
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ Blockbench tools for creating multi-actor animations and animation definitions f
|
|||||||
|
|
||||||
- **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. 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).
|
- **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).
|
||||||
|
- **Particle Previews** enables native timeline previews for NoN's ordinary/falling liquid, small hearts, and ripped fabric. Load their textures from a NoN JAR or extracted assets folder using the particle library. Recognized imported cues are mapped automatically when their locators exist. Previews approximate motion with neutral colors; Minecraft collisions, water interactions and gameplay effects are not simulated. Preview references are excluded from animation exports. Disable previews in the particle library or animation tools menu.
|
||||||
- **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 a looping clip's start to its end. They remain visible but gray out when their requirements are not met.
|
||||||
- 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.
|
||||||
- **Tools > AnimationDirector Model > Create JEM Model** transfers selected additions into a CEM preset. It does not transfer animation keyframes, locators, or modifications to existing vanilla cubes. Review the resulting model and save its textures alongside the exported JEM as appropriate.
|
- **Tools > AnimationDirector Model > Create JEM Model** transfers selected additions into a CEM preset. It does not transfer animation keyframes, locators, or modifications to existing vanilla cubes. Review the resulting model and save its textures alongside the exported JEM as appropriate.
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function harness() {
|
|||||||
remove() { Cube.all = Cube.all.filter((cube) => cube !== this); }
|
remove() { Cube.all = Cube.all.filter((cube) => cube !== this); }
|
||||||
}
|
}
|
||||||
const context = {
|
const context = {
|
||||||
|
localStorage: { getItem() { return null; }, setItem() {} },
|
||||||
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
|
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
|
||||||
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
|
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
|
||||||
Toggle: class { constructor(id, options) { Object.assign(this, options, { id, value: false }); } },
|
Toggle: class { constructor(id, options) { Object.assign(this, options, { id, value: false }); } },
|
||||||
@@ -155,7 +156,7 @@ test('animation menu orders import and creation actions and has no deprecated sp
|
|||||||
assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [
|
assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [
|
||||||
'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||||
'_', 'Actor Preferences', 'Replace Actor Model',
|
'_', 'Actor Preferences', 'Replace Actor Model',
|
||||||
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'NoN Particle Library',
|
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'NoN Particle Library', 'Particle Previews',
|
||||||
'_', 'Animation Definition', 'Export Animations',
|
'_', 'Animation Definition', 'Export Animations',
|
||||||
]);
|
]);
|
||||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ function harness() {
|
|||||||
Blockbench: { showQuickMessage(message) { context.message = message; } }, console,
|
Blockbench: { showQuickMessage(message) { context.message = message; } }, console,
|
||||||
};
|
};
|
||||||
const code = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
|
const code = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
|
||||||
.replace(' Plugin.register(PLUGIN_ID, {', ' globalThis.api = { previousStagePoseSource, copyPreviousStagePose, copyFirstPoseToEnd, loopPoseUnavailableReason, refreshPreviousStagePoseButton, setButton(button) { previousStagePoseAction = button; } };\n Plugin.register(PLUGIN_ID, {');
|
.replace(' Plugin.register(PLUGIN_ID, {', ' globalThis.api = { previousStagePoseSource, copyPreviousStagePose, copyFirstPoseToEnd, loopPoseUnavailableReason, refreshPreviousStagePoseButton, refreshLoopPoseButton, setButton(button) { previousStagePoseAction = button; }, setLoopButton(button) { loopPoseAction = button; } };\n Plugin.register(PLUGIN_ID, {');
|
||||||
vm.runInNewContext(code, context);
|
vm.runInNewContext(code, context);
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function animation(name, length = 2) {
|
function animation(name, length = 2) {
|
||||||
return { name, length, animators: {}, getBoneAnimator(group) {
|
return { name, length, loop: 'loop', animators: {}, getBoneAnimator(group) {
|
||||||
return this.animators[group.uuid] ||= animator();
|
return this.animators[group.uuid] ||= animator();
|
||||||
} };
|
} };
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,8 @@ function animator() {
|
|||||||
|
|
||||||
test('previous pose button stays visible and explains why it cannot be used', () => {
|
test('previous pose button stays visible and explains why it cannot be used', () => {
|
||||||
const h = harness(), states = [], label = {};
|
const h = harness(), states = [], label = {};
|
||||||
const button = { nodes: [{ classList: { toggle(name, value) { states.push(value); } }, setAttribute() {}, querySelector() { return label; } }] };
|
const node = { style: {}, classList: { toggle(name, value) { states.push(value); } }, setAttribute() {}, querySelector() { return label; } };
|
||||||
|
const button = { node, nodes: [node] };
|
||||||
h.api.setButton(button);
|
h.api.setButton(button);
|
||||||
h.api.refreshPreviousStagePoseButton();
|
h.api.refreshPreviousStagePoseButton();
|
||||||
assert.match(label.textContent, /Select an animation stage/);
|
assert.match(label.textContent, /Select an animation stage/);
|
||||||
@@ -43,6 +44,12 @@ test('previous pose button stays visible and explains why it cannot be used', ()
|
|||||||
h.api.refreshPreviousStagePoseButton();
|
h.api.refreshPreviousStagePoseButton();
|
||||||
assert.match(label.textContent, /no previous stage/);
|
assert.match(label.textContent, /no previous stage/);
|
||||||
assert.deepEqual(states, [true, true]);
|
assert.deepEqual(states, [true, true]);
|
||||||
|
assert.equal(node.style.filter, 'grayscale(1)');
|
||||||
|
assert.equal(node.style.opacity, '0.35');
|
||||||
|
const second = animation('example.p2'); h.Animation.all.push(second); h.Animation.selected = second;
|
||||||
|
h.api.refreshPreviousStagePoseButton();
|
||||||
|
assert.equal(node.style.filter, '');
|
||||||
|
assert.equal(node.style.opacity, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('previous stage uses numeric ordering, same animation ID and saved reused stages', () => {
|
test('previous stage uses numeric ordering, same animation ID and saved reused stages', () => {
|
||||||
@@ -132,3 +139,20 @@ test('loop pose is unavailable without a clip or with a zero length', () => {
|
|||||||
assert.match(h.message, /positive animation clip length/);
|
assert.match(h.message, /positive animation clip length/);
|
||||||
assert.equal(h.edits.length, 0);
|
assert.equal(h.edits.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loop pose button is visibly disabled for non-looping animations and cannot edit them', () => {
|
||||||
|
const h = harness(), clip = animation('example.p1'), node = {
|
||||||
|
style: {}, classList: { toggle() {} }, setAttribute() {}, querySelector() { return null; },
|
||||||
|
};
|
||||||
|
h.api.setLoopButton({ node, nodes: [node] });
|
||||||
|
h.Animation.selected = clip;
|
||||||
|
for (const loop of ['once', 'hold']) {
|
||||||
|
clip.loop = loop; h.api.refreshLoopPoseButton();
|
||||||
|
assert.equal(node.style.filter, 'grayscale(1)');
|
||||||
|
assert.match(h.api.loopPoseUnavailableReason(), /Set this animation to Loop/);
|
||||||
|
h.api.copyFirstPoseToEnd(); assert.equal(h.edits.length, 0);
|
||||||
|
}
|
||||||
|
clip.loop = 'loop'; h.api.refreshLoopPoseButton();
|
||||||
|
assert.equal(node.style.filter, '');
|
||||||
|
assert.equal(h.api.loopPoseUnavailableReason(), null);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user