added multi gender export with exclusion for optifine models

This commit is contained in:
2026-10-01 01:58:07 +02:00
parent c1c94a7e16
commit 3e26c7a657
3 changed files with 610 additions and 26 deletions
+163
View File
@@ -0,0 +1,163 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function variantBuilder() {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.variantTest = { buildGenderModelVariant, refreshModelVariantFields, createModelVariantBoneSections, buildJemModelVariant };\n Plugin.register(PLUGIN_ID, {'
);
const context = { Plugin: { register() {} }, console };
vm.runInNewContext(source, context);
return Object.assign(context.variantTest.buildGenderModelVariant, { refresh: context.variantTest.refreshModelVariantFields,
sections: context.variantTest.createModelVariantBoneSections, jem: context.variantTest.buildJemModelVariant });
}
test('gender variants omit selected bones, descendants, locators and texture overrides without changing source', () => {
const build = variantBuilder();
const source = { format_version: '1.12.0', 'minecraft:geometry': [{
description: { identifier: 'geometry.example', texture_width: 64 },
bones: [
{ name: 'detail', parent: 'd', cubes: [{ origin: [0, 0, 0] }] },
{ name: 'body', cubes: [{}] },
{ name: 'd', parent: 'body', cubes: [{}], locators: { D: [0, 1, 0] } },
{ name: 'head', parent: 'body', cubes: [{}] },
],
}], afw_bone_textures: { d: 'example:textures/custom.png' } };
const before = JSON.stringify(source);
const female = JSON.parse(JSON.stringify(build(source, ['d'])));
assert.deepEqual(female['minecraft:geometry'][0].bones.map((bone) => bone.name), ['body', 'head']);
assert.deepEqual(female['minecraft:geometry'][0].bones[0].cubes, [{}]);
assert.equal(female.afw_bone_textures, undefined);
assert.equal(JSON.stringify(build(source, [])), before);
assert.equal(JSON.stringify(source), before);
});
test('variant subtree propagation handles nested children and unknown saved selections', () => {
const build = variantBuilder();
const source = { 'minecraft:geometry': [{ bones: [
{ name: 'grandchild', parent: 'child', cubes: [{}] },
{ name: 'child', parent: 'root', cubes: [{}] },
{ name: 'root', cubes: [{}] },
] }] };
const hidden = build(source, ['root', 'deleted']);
assert.equal(hidden['minecraft:geometry'][0].bones.length, 0);
});
test('selected hidden parents disable and clear descendant selections separately for each gender', () => {
const build = variantBuilder();
const bones = [{ name: 'body' }, { name: 'child', parent: 'body' }, { name: 'tip', parent: 'child' }];
const fields = {}, updates = [];
for (const gender of ['m', 'f', 'mf']) for (let index = 0; index < bones.length; index++) {
const input = { disabled: false };
fields[`hide_${gender}_${index}`] = { input, bar: { style: {}, querySelectorAll: () => [input] } };
}
const dialog = { form: { form_data: fields, setValues: (values) => updates.push(values) } };
const values = { hide_m_0: true, hide_m_1: true, hide_m_2: true, hide_f_1: true, hide_mf_0: true, hide_mf_2: true };
build.refresh(dialog, bones, values);
assert.equal(values.hide_m_1, false);
assert.equal(values.hide_m_2, false);
assert.equal(fields.hide_m_1.input.disabled, true);
assert.equal(fields.hide_m_2.input.disabled, true);
assert.equal(fields.hide_f_1.input.disabled, false);
assert.equal(fields.hide_f_2.input.disabled, true);
assert.equal(values.hide_mf_2, false);
assert.equal(fields.hide_mf_2.input.disabled, true);
assert.match(fields.hide_m_2.bar.title, /Already excluded/);
values.hide_m_0 = false;
build.refresh(dialog, bones, values);
assert.equal(fields.hide_m_2.input.disabled, false);
});
test('each gender bone list gets an independent bounded scroll container', () => {
const build = variantBuilder();
const nodes = [];
const document = { createElement() {
return { style: {}, children: [], appendChild(child) {
nodes.splice(nodes.indexOf(child), 1);
this.children.push(child);
child.parentNode = this;
} };
} };
const parent = { insertBefore(node, reference) { nodes.splice(nodes.indexOf(reference), 0, node); } };
const fields = {};
for (const gender of ['m', 'f', 'mf']) for (let index = 0; index < 2; index++) {
const bar = { parentNode: parent, ownerDocument: document, style: {}, querySelectorAll: () => [] };
fields[`hide_${gender}_${index}`] = { bar };
nodes.push(bar);
}
const dialog = { form: { form_data: fields } };
const bones = [{ name: 'body' }, { name: 'head', parent: 'body' }];
build.sections(dialog, bones);
assert.equal(nodes.length, 3);
for (const gender of ['m', 'f', 'mf']) {
const section = dialog.modelVariantBoneSections[gender];
assert.equal(section.children.length, 2);
assert.equal(section.style.maxHeight, '160px');
assert.equal(section.style.overflowY, 'auto');
assert.equal(section.children[0], fields[`hide_${gender}_0`].bar);
}
build.refresh(dialog, bones, { m: true, f: false, mf: true });
assert.equal(dialog.modelVariantBoneSections.f.style.display, 'none');
assert.equal(dialog.modelVariantBoneSections.mf.style.display, '');
});
test('JEM exclusion removes whole subtrees without changing remaining roots or source', () => {
const model = { texture: 'custom.png', textureSize: [64, 64], models: [{ part: 'body', id: 'body',
boxes: [{}], submodels: [{ id: 'd', boxes: [{}], sprites: [{}],
animations: [{ 'd.visible': 'nbt(NeedsOfNatureEnergy,range:70-200)' }],
submodel: { id: 'tip', boxes: [{}] } }, { id: 'detail', boxes: [{}] }] }] };
const original = JSON.stringify(model);
const output = JSON.parse(JSON.stringify(variantBuilder().jem(model, ['0/0'])));
assert.deepEqual(output.models[0].boxes, [{}]);
assert.equal(output.models[0].submodels.length, 1);
assert.equal(output.models[0].submodels[0].id, 'detail');
assert.deepEqual(output.models[0].submodels[0].boxes, [{}]);
assert.equal(output.texture, 'custom.png');
assert.equal(JSON.stringify(model), original);
});
test('JEM export opens the shared variant screen directly and writes numbered gender files', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.variantTest = { showCreateJemModel };\n Plugin.register(PLUGIN_ID, {'
);
const model = { models: [{ part: 'body', id: 'body', boxes: [{}], submodels: [{ id: 'd', boxes: [{}] }] }] };
let dialog;
const files = [], errors = [];
const codec = { compile: () => model, fileName: () => 'wolf' };
const context = { Plugin: { register() {} }, Project: { name: 'wolf' },
Format: { id: 'optifine_entity', codec }, Formats: { optifine_entity: { codec } }, console,
Dialog: class {
constructor(options) { dialog = options; this.form = { form_data: {} }; }
show() {}
getFormResult() { return Object.fromEntries(Object.entries(dialog.form).map(([key, field]) => [key, field.value])); }
},
Blockbench: { export: (file, callback) => { files.push(file); callback(); },
showMessageBox: (error) => errors.push(error.message) },
};
vm.runInNewContext(source, context);
context.variantTest.showCreateJemModel();
assert.equal(dialog.title, 'Export JEM Models');
assert.equal(dialog.form.hide_mf_1.label, 'd');
const result = Object.fromEntries(Object.entries(dialog.form).map(([key, field]) => [key, field.value]));
Object.assign(result, { base: false, m: true, f: true, mf: true, hide_f_1: true });
dialog.onConfirm(result);
assert.deepEqual(errors, []);
assert.deepEqual(files.map((file) => file.name), ['wolf1', 'wolf2', 'wolf3']);
assert.deepEqual(Array.from(files[0].extensions), ['jem']);
assert.equal(JSON.parse(files[1].content).models[0].submodels.length, 0);
assert.deepEqual(JSON.parse(files[2].content), model);
assert.equal(JSON.parse(context.Project.animationdirector_jem_variants).hide_f[0], '0/0');
});
test('JEM exclusion supports singular submodels and clears animation targets for removed IDs', () => {
const model = { models: [{ id: 'body', submodel: { id: 'd', boxes: [{}] },
animations: [{ 'd.visible': 'true', 'body.visible': 'true' }] }, { id: 'head' }] };
const output = JSON.parse(JSON.stringify(variantBuilder().jem(model, ['0/single', '1'])));
assert.equal(output.models.length, 1);
assert.equal(output.models[0].submodel, undefined);
assert.deepEqual(output.models[0].animations, [{ 'body.visible': 'true' }]);
});
+344
View File
@@ -0,0 +1,344 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const test = require('node:test');
function harness() {
const storage = new Map(), edits = [], elements = new Map();
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
.replace(' Plugin.register(PLUGIN_ID, {', `
globalThis.api = { loadParticleLibrary, particleJsonPaths, particleThumbnailRows, availableParticleLocators, applyLibraryParticle,
showParticleLibrary, refreshSoundLibraryPanel, NON_PARTICLE_IDS, PARTICLE_SOURCE_KEY,
buildParticlePreview, mapAnimationParticles, clearParticlePreviews, setParticlePreviewsEnabled,
PARTICLE_PREVIEW_PROPERTY, PARTICLE_PREVIEW_PREFIX, cleanEffectPreviewFields };
Plugin.register(PLUGIN_ID, {`);
class EffectAnimator {
constructor(animation) { this.animation = animation; this.particle = []; }
select() {}
createKeyframe(data, time, channel) {
const frame = { channel, time, animator: this, select() {},
extend(data) { this.data_points = data.data_points.map((point) => ({ ...point, getUndoCopy() { return { ...this }; } })); } };
frame.extend(data); this[channel].push(frame); return frame;
}
}
const context = {
Plugin: { register() {} }, Buffer, requireNativeModule: require, console,
Format: { id: 'geckolib_model' },
localStorage: { getItem(key) { return storage.get(key); }, setItem(key, value) { storage.set(key, value); } },
Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] }, Locator: { all: [] },
Undo: { initEdit(aspects) { edits.push({ before: aspects.keyframes.slice() }); },
finishEdit(label, aspects) { Object.assign(edits.at(-1), { label, after: aspects?.keyframes.slice() }); } },
Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} },
Blockbench: { isWeb: false },
document: {
getElementById(id) { return elements.get(id); },
createElement() { return { style: {}, addEventListener(event, callback) { this.click = callback; },
remove() { elements.delete(this.id); } }; },
},
};
elements.set('panel_keyframe', { appendChild(button) { elements.set(button.id, button); } });
vm.runInNewContext(source, context);
return { ...context.api, context, storage, edits, elements };
}
test('particle library includes all registered NoN particles without an asset source', () => {
const h = harness();
const library = h.loadParticleLibrary();
const registry = fs.readFileSync(path.join(__dirname, '..', '..', 'NON', 'src', 'main', 'java', 'com', 'nonid', 'particle', 'NonParticles.java'), 'utf8');
const registered = [...registry.matchAll(/NeedsOfNature\.id\("([a-z_]+)"\)/g)].map((match) => `needsofnature:${match[1]}`).sort();
assert.deepEqual(Array.from(library.ids), registered);
assert.equal(library.warnings.length, 0);
});
test('particle library discovers nested IDs from asset folders and deduplicates built-ins', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particles-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const name of ['minecraft/particles/heart.json', 'addon/particles/nested/spark.json',
'needsofnature/particles/smallheart.json', 'minecraft/textures/particle/not_a_particle.json']) {
const file = path.join(root, 'assets', name);
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, '{"textures":[]}');
}
h.storage.set(h.PARTICLE_SOURCE_KEY, path.join(root, 'assets'));
const library = h.loadParticleLibrary();
assert.ok(library.ids.includes('minecraft:heart'));
assert.ok(library.ids.includes('addon:nested/spark'));
assert.equal(library.ids.filter((id) => id === 'needsofnature:smallheart').length, 1);
assert.ok(library.ids.slice(0, h.NON_PARTICLE_IDS.length).every((id) => id.startsWith('needsofnature:')),
'NoN particles appear before every other namespace');
assert.deepEqual(Array.from(library.ids.slice(h.NON_PARTICLE_IDS.length)), ['addon:nested/spark', 'minecraft:heart'],
'Other particles remain alphabetically sorted');
assert.ok(!library.ids.some((id) => id.includes('not_a_particle')));
assert.equal(library.warnings.length, 0);
const archive = { files: new Map(['assets/minecraft/particles/heart.json',
'assets/test/particles/sub/spark.json', 'assets/test/particles/../escape.json', 'assets/test/sounds.json'].map((name) => [name, {}])) };
const names = h.particleJsonPaths(archive, { fs, path });
assert.ok(names.includes('assets/test/particles/sub/spark.json'));
// IDs are additionally validated before the catalog accepts archive entries.
assert.equal(names.includes('assets/test/sounds.json'), false);
});
test('applying a particle preserves locators and scripts while removing an unrelated preview file', () => {
const h = harness(), animation = h.context.Animation.selected;
const effects = new h.context.EffectAnimator(animation);
const frame = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor1_d', script: 'custom', file: 'old.json' },
{ effect: 'minecraft:smoke', locator: 'actor2_d' }] }, 1, 'particle');
h.context.Timeline.selected = [frame];
h.applyLibraryParticle('needsofnature:liquid_particle_falling');
assert.equal(frame.data_points[0].effect, 'needsofnature:liquid_particle_falling');
assert.equal(frame.data_points[0].locator, 'actor1_d');
assert.equal(frame.data_points[0].script, 'custom');
assert.equal(frame.data_points[0].file, '');
assert.equal(frame.data_points[1].effect, 'minecraft:smoke');
assert.equal(h.edits[0].before[0], frame);
assert.equal(h.edits[0].label, 'Apply particle from library');
});
test('particle insertion uses the playhead and appends cues without overwriting existing ones', () => {
const h = harness();
h.applyLibraryParticle('minecraft:heart', true);
const frames = h.context.Animation.selected.animators.effects.particle;
assert.equal(frames.length, 1);
assert.equal(frames[0].time, 2);
assert.equal(frames[0].channel, 'particle');
assert.equal(h.edits[0].before.length, 0);
assert.equal(h.edits[0].after[0], frames[0]);
frames[0].data_points[0].locator = 'actor1_d';
h.applyLibraryParticle('minecraft:smoke', true);
assert.equal(frames.length, 1);
assert.equal(frames[0].data_points.length, 2);
assert.equal(frames[0].data_points[0].locator, 'actor1_d');
assert.equal(frames[0].data_points[1].effect, 'minecraft:smoke');
assert.equal(frames[0].data_points[1].file, undefined, 'No particle preview is assigned');
h.context.Animation.selected = null;
assert.throws(() => h.applyLibraryParticle('minecraft:heart'), /Select an animation/);
assert.throws(() => h.applyLibraryParticle('../escape'), /Invalid particle ID/);
});
test('keyframe library buttons match their channels and do not duplicate on refresh', () => {
const h = harness(), particleId = 'animationdirector_particle_library_button', soundId = 'animationdirector_sound_library_button';
for (const channels of [[], ['particle'], ['sound'], ['rotation'], ['particle', 'sound'], ['particle', 'particle']]) {
h.context.Timeline.selected = channels.map((channel) => ({ channel }));
h.refreshSoundLibraryPanel();
assert.equal(h.elements.has(particleId), channels.length > 0 && channels.every((channel) => channel === 'particle'));
assert.equal(h.elements.has(soundId), channels.length > 0 && channels.every((channel) => channel === 'sound'));
}
const button = h.elements.get(particleId);
h.refreshSoundLibraryPanel(); assert.equal(h.elements.get(particleId), button);
button.click(); assert.equal(h.context.dialog.title, 'NoN Particle Library');
const component = h.context.dialog.component, data = component.data();
data.query = 'liquid'; data.category = 'needsofnature';
const rows = component.computed.rows.call(data);
assert.ok(rows.length > 0 && rows.every((id) => id.startsWith('needsofnature:') && id.includes('liquid')));
assert.doesNotMatch(component.template, /@click="preview|@click="play/);
h.context.Blockbench.isWeb = true; h.refreshSoundLibraryPanel();
assert.equal(h.elements.has(particleId), false);
});
test('locator selector lists unique project locators and preselects the current cue locator', () => {
const h = harness(), animation = h.context.Animation.selected;
h.context.Locator.all = [{ name: 'actor2_d' }, { name: 'actor1_d' }, { name: 'actor2_d' }];
assert.deepEqual(Array.from(h.availableParticleLocators()), ['actor1_d', 'actor2_d']);
h.showParticleLibrary();
let data = h.context.dialog.component.data();
assert.equal(data.locator, 'actor1_d', 'New cues default to the first available locator');
const effects = new h.context.EffectAnimator(animation);
const frame = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor2_d' }] }, 1, 'particle');
h.context.Timeline.selected = [frame];
h.showParticleLibrary();
data = h.context.dialog.component.data();
assert.equal(data.locator, 'actor2_d');
data.selected = 'minecraft:smoke'; data.locator = 'actor1_d';
h.context.dialog.component.methods.apply.call(data, false);
assert.equal(frame.data_points[0].locator, 'actor1_d');
assert.equal(frame.data_points[0].effect, 'minecraft:smoke');
assert.equal(data.error, '');
h.applyLibraryParticle('minecraft:heart', true, 'actor2_d');
assert.equal(animation.animators.effects.particle[0].data_points[0].locator, 'actor2_d');
h.context.Locator.all = [];
assert.throws(() => h.applyLibraryParticle('minecraft:heart', false, 'actor2_d'), /no longer available/);
});
test('mixed locator selections keep their existing locators unless explicitly changed', () => {
const h = harness(), effects = new h.context.EffectAnimator(h.context.Animation.selected);
h.context.Locator.all = [{ name: 'actor1_d' }, { name: 'actor2_d' }];
const first = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor1_d' }] }, 1, 'particle');
const second = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor2_d' }] }, 2, 'particle');
h.context.Timeline.selected = [first, second];
h.showParticleLibrary();
assert.equal(h.context.dialog.component.data().locator, '');
h.applyLibraryParticle('minecraft:smoke');
assert.equal(first.data_points[0].locator, 'actor1_d');
assert.equal(second.data_points[0].locator, 'actor2_d');
second.data_points[0].locator = '';
h.showParticleLibrary();
assert.equal(h.context.dialog.component.data().locator, '', 'A mix of assigned and unassigned cues also stays unchanged');
});
test('sprite thumbnails use the first texture, support cross-source assets and cache only image data', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-sprites-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const write = (name, data) => {
const file = path.join(root, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, data); return file;
};
const sprite = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXaAAAAAASUVORK5CYII=', 'base64');
write('pack/assets/test/particles/spark.json', JSON.stringify({ textures: ['other:bright', 'other:second'] }));
const png = write('sprites/assets/other/textures/particle/bright.png', sprite);
write('pack/assets/test/particles/broken.json', '{ invalid json');
write('pack/assets/test/particles/unsafe.json', JSON.stringify({ textures: ['other:../escape'] }));
write('pack/assets/test/particles/empty.json', JSON.stringify({ textures: [] }));
h.storage.set(h.PARTICLE_SOURCE_KEY, path.join(root, 'pack'));
h.storage.set('animationdirector_prop_source', path.join(root, 'sprites'));
const library = h.loadParticleLibrary();
assert.equal(library.thumbnails.size, 0, 'Opening the catalog does not eagerly decode all sprites');
const rows = h.particleThumbnailRows(['test:spark', 'test:broken', 'test:unsafe', 'test:empty', 'needsofnature:smallheart'], library);
assert.equal(rows[0].thumbnail, `data:image/png;base64,${sprite.toString('base64')}`);
assert.ok(rows.slice(1).every((row) => row.thumbnail === ''));
assert.equal(library.thumbnailBytes, sprite.length);
fs.unlinkSync(png);
assert.equal(h.particleThumbnailRows(['test:spark'], library)[0].thumbnail, rows[0].thumbnail);
assert.equal(library.thumbnailBytes, sprite.length, 'Repeated renders do not grow the cache');
const refreshed = h.loadParticleLibrary();
assert.equal(h.particleThumbnailRows(['test:spark'], refreshed)[0].thumbnail, '', 'Refresh clears cached sprites');
});
test('sprite thumbnails enforce image dimensions and total cache limits', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-limits-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const particles = path.join(root, 'assets/test/particles'), textures = path.join(root, 'assets/test/textures/particle');
fs.mkdirSync(particles, { recursive: true }); fs.mkdirSync(textures, { recursive: true });
fs.writeFileSync(path.join(particles, 'big.json'), JSON.stringify({ textures: ['test:big'] }));
const bytes = Buffer.alloc(24);
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
bytes.writeUInt32BE(2048, 16); bytes.writeUInt32BE(1, 20);
fs.writeFileSync(path.join(textures, 'big.png'), bytes);
h.storage.set(h.PARTICLE_SOURCE_KEY, root);
const library = h.loadParticleLibrary();
assert.equal(h.particleThumbnailRows(['test:big'], library)[0].thumbnail, '');
assert.equal(library.thumbnailBytes, 0);
library.thumbnails.clear(); library.thumbnailBytes = 8 * 1024 * 1024;
assert.equal(h.particleThumbnailRows(['test:big'], library)[0].thumbnail, '');
assert.deepEqual(Array.from(h.particleThumbnailRows(['test:big'], null), (row) => row.thumbnail), ['']);
});
function previewHarness(t) {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-preview-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const particles = path.join(root, 'assets/needsofnature/particles'), textures = path.join(root, 'assets/needsofnature/textures/particle');
fs.mkdirSync(particles, { recursive: true }); fs.mkdirSync(textures, { recursive: true });
const sprite = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXaAAAAAASUVORK5CYII=', 'base64');
for (const name of ['smallheart', 'liquid_particle', 'liquid_particle_falling', 'ripped_fabric']) {
fs.writeFileSync(path.join(particles, `${name}.json`), JSON.stringify({ textures: [`needsofnature:${name}`] }));
fs.writeFileSync(path.join(textures, `${name}.png`), sprite);
}
h.storage.set(h.PARTICLE_SOURCE_KEY, root);
h.context.Locator.all = [{ name: 'actor1_d' }];
h.context.WinterskyScene = {};
h.context.Wintersky = { Config: class {
constructor(scene, values) { this.scene = scene; Object.assign(this, values); this.texture = { dispose() { this.disposed = true; } }; }
setFromJSON(json) { this.json = json; return this; }
} };
h.context.Animator = { particle_effects: {}, preview() {} };
const animation = h.context.Animation.selected;
h.context.Animation.all = [animation];
const effects = animation.animators.effects = new h.context.EffectAnimator(animation);
const add = (effect, locator = 'actor1_d', extra = {}) => effects.createKeyframe({ data_points: [{ effect, locator, ...extra }] }, 1, 'particle').data_points[0];
return { ...h, add, sprite };
}
test('NoN preview presets are one-shot, world-space particles with a one-pixel spawn offset', () => {
const h = harness(), png = Buffer.alloc(24);
png.writeUInt32BE(16, 16); png.writeUInt32BE(80, 20);
const texture = `data:image/png;base64,${png.toString('base64')}`;
for (const id of ['liquid_particle', 'liquid_particle_falling', 'smallheart', 'ripped_fabric']) {
const json = h.buildParticlePreview(`needsofnature:${id}`, texture), c = json.particle_effect.components;
assert.equal(c['minecraft:emitter_rate_instant'].num_particles, 1);
assert.ok(c['minecraft:emitter_lifetime_once'].active_time > 0);
assert.equal(c['minecraft:emitter_local_space'].position, false);
assert.equal(c['minecraft:emitter_shape_point'].offset[1], -1 / 16);
if (id === 'smallheart') assert.equal(c['minecraft:particle_initial_speed'], 2);
if (id === 'liquid_particle_falling') {
assert.equal(c['minecraft:particle_motion_dynamic'].linear_acceleration[1], -2.4);
assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.12);
}
if (id === 'liquid_particle') {
assert.match(c['minecraft:particle_motion_parametric'].relative_position[1], /particle_age < 1/);
assert.match(c['minecraft:particle_motion_parametric'].relative_position[1], /-1\.2 \*/);
assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.12);
}
if (id === 'ripped_fabric') {
assert.equal(c['minecraft:particle_appearance_billboard'].uv.flipbook.max_frame, 5);
assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.19);
assert.equal(c['minecraft:particle_motion_dynamic'].linear_acceleration[1], -0.16);
}
}
assert.equal(h.buildParticlePreview('minecraft:dust', texture), null);
assert.equal(h.buildParticlePreview('needsofnature:smallheart', ''), null);
});
test('native preview mapping preserves gameplay cues, locators and manual preview files', (t) => {
const h = previewHarness(t);
const heart = h.add('needsofnature:smallheart', 'actor1_d', { script: 'v.custom = 1;' });
const manual = h.add('needsofnature:smallheart', 'actor1_d', { file: 'manual.json' });
const missingLocator = h.add('needsofnature:liquid_particle', 'missing');
const unknown = h.add('minecraft:dust');
h.mapAnimationParticles(h.context.Animation.all);
const key = `${h.PARTICLE_PREVIEW_PREFIX}needsofnature:smallheart`;
const native = h.context.Animator.particle_effects[key];
assert.ok(native);
assert.equal(native.config.scene, h.context.WinterskyScene);
assert.equal(native.config.preview_texture, `data:image/png;base64,${h.sprite.toString('base64')}`);
assert.equal(heart.file, key);
assert.equal(heart.effect, 'needsofnature:smallheart');
assert.equal(heart.locator, 'actor1_d');
assert.equal(heart.script, 'v.custom = 1;');
assert.equal(manual.file, 'manual.json');
assert.ok(!missingLocator.file);
assert.ok(!unknown.file);
h.mapAnimationParticles(h.context.Animation.all);
assert.equal(h.context.Animator.particle_effects[key], native, 'Repeated selection reuses the config');
heart.effect = 'needsofnature:liquid_particle_falling'; h.mapAnimationParticles(h.context.Animation.all);
assert.equal(heart.file, `${h.PARTICLE_PREVIEW_PREFIX}needsofnature:liquid_particle_falling`);
h.context.Locator.all = []; h.mapAnimationParticles(h.context.Animation.all);
assert.equal(heart.file, '', 'Missing locators do not preview at the actor origin');
});
test('disabling and refreshing previews disposes emitters and regenerates saved virtual references', (t) => {
const h = previewHarness(t), point = h.add('needsofnature:smallheart');
h.mapAnimationParticles(h.context.Animation.all);
const key = point.file, native = h.context.Animator.particle_effects[key];
let deleted = 0;
native.emitters.test = { delete() { deleted++; } };
h.setParticlePreviewsEnabled(false);
assert.equal(deleted, 1);
assert.equal(native.config.texture.disposed, true);
assert.equal(h.context.Animator.particle_effects[key], undefined);
assert.equal(point.file, '');
assert.equal(h.storage.get('animationdirector_particle_previews'), 'false');
point.file = key; point[h.PARTICLE_PREVIEW_PROPERTY] = key;
h.mapAnimationParticles(h.context.Animation.all);
assert.equal(point.file, '', 'Disabled previews remove saved virtual references');
h.setParticlePreviewsEnabled(true);
assert.equal(point.file, key);
const next = h.context.Animator.particle_effects[key];
h.loadParticleLibrary();
assert.equal(next.config.texture.disposed, true);
assert.equal(point.file, '');
h.mapAnimationParticles(h.context.Animation.all);
assert.equal(point.file, key);
h.clearParticlePreviews(); assert.equal(point.file, '');
});
test('particle preview fields are removed from every exported cue without changing the source project', () => {
const h = harness(), source = { particle_effects: { '0.1': [{ effect: 'needsofnature:smallheart',
locator: 'actor1_d', script: 'test', file: 'virtual.json', [h.PARTICLE_PREVIEW_PROPERTY]: 'virtual.json' }],
'0.2': { effect: 'needsofnature:liquid_particle', locator: 'actor2_d', file: 'virtual.json' } } };
const exported = h.cleanEffectPreviewFields(JSON.parse(JSON.stringify(source)));
assert.equal(exported.particle_effects['0.1'][0].file, undefined);
assert.equal(exported.particle_effects['0.1'][0][h.PARTICLE_PREVIEW_PROPERTY], undefined);
assert.equal(exported.particle_effects['0.1'][0].locator, 'actor1_d');
assert.equal(exported.particle_effects['0.1'][0].script, 'test');
assert.equal(exported.particle_effects['0.2'].effect, 'needsofnature:liquid_particle');
assert.equal(source.particle_effects['0.1'][0].file, 'virtual.json');
});