Compare commits

...
9 Commits
Author SHA1 Message Date
L1Z0 b6bedaf41e fixed clip lengths not syncing 2026-10-01 21:39:11 +02:00
L1Z0 9241b35291 auto reapply textures when selecting texture 2026-10-01 02:04:46 +02:00
L1Z0 8aa71244b6 tweak 2026-10-01 02:00:33 +02:00
L1Z0 3e26c7a657 added multi gender export with exclusion for optifine models 2026-10-01 01:58:07 +02:00
L1Z0 c1c94a7e16 added multi gender export with hiding 2026-10-01 01:48:19 +02:00
L1Z0 a249f1bbdd fixes 2026-10-01 01:35:33 +02:00
L1Z0 e7bb16df64 particle library 2026-09-29 16:36:08 +02:00
L1Z0 2780f8aebb particle library 2026-09-29 16:13:42 +02:00
L1Z0 13b3c9dd77 particle library 2026-09-29 16:07:35 +02:00
9 changed files with 1387 additions and 60 deletions
+712 -47
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -8,7 +8,7 @@ Blockbench tools for creating multi-actor animations and animation definitions f
- Supported baseline: Blockbench **5.2.1 or newer**.
- Enable the **GeckoLib** format plugin to create/import multi-actor animation projects.
- **Create JEM Model** requires **CEM Template Loader**. Its preset loader may require an internet connection.
- Desktop Blockbench is required for automatic import, pack folders, item props, JEM transfer, and the NoN sound library. These actions are hidden in browser Blockbench; manual JSON import/export remains available.
- Desktop Blockbench is required for automatic import, pack folders, item props, JEM transfer, and the NoN sound and particle libraries. These actions are hidden in browser Blockbench; manual JSON import/export remains available.
- **Asset Browser** is optional. AnimationDirector can use its downloaded Minecraft asset cache, installed Minecraft client archives, or manually selected assets. It does not download vanilla textures itself.
## Workflow
@@ -23,8 +23,10 @@ Blockbench tools for creating multi-actor animations and animation definitions f
## Preview Tools
- **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).
- **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.
- 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.
- **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.
+60 -3
View File
@@ -8,7 +8,7 @@ function createHarness() {
const file = path.join(__dirname, '..', 'AnimationDirector.js');
const source = fs.readFileSync(file, 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.adTest = { showAnimationDefinitionReviewDialog, createInjectorGenderDefaultHandler };\n Plugin.register(PLUGIN_ID, {'
' globalThis.adTest = { showAnimationDefinitionReviewDialog, createInjectorGenderDefaultHandler, remapActorReferences, readStageDefinitionFromForm };\n Plugin.register(PLUGIN_ID, {'
);
const dialogs = [];
const exports = [];
@@ -45,7 +45,7 @@ function createHarness() {
context.adTest.showAnimationDefinitionReviewDialog(group, analysis);
return dialogs.at(-1);
}
return { open, exports, errors, project, analysis, createInjectorGenderDefaultHandler: context.adTest.createInjectorGenderDefaultHandler,
return { open, exports, errors, project, analysis, helpers: context.adTest, createInjectorGenderDefaultHandler: context.adTest.createInjectorGenderDefaultHandler,
setFormResult(result) { formResult = result; } };
}
@@ -55,6 +55,50 @@ function defaults(form) {
.map(([key, field]) => [key, field.value]));
}
test('actor renames remap all definitions, metadata and prop previews simultaneously', () => {
const h = createHarness();
const metadata = { 1: { injects_into: ['wolf'] } };
const definition = { actors: [{ label: 'player', injects_into: 'wolf', prop_right: 'minecraft:stick' },
{ label: 'wolf' }], position_anchor_actor: 'wolf', stages: [{ stage: 1, props: { wolf: null, player: { prop_left: null } } }] };
h.project.animationdirector_animdef_definitions = JSON.stringify({ first: definition, second: definition });
h.project.animationdirector_prop_previews = JSON.stringify({ old: { animation: 'first', actor: 'wolf', slot: 'right', stage: 1 } });
h.helpers.remapActorReferences({ 1: 'player', 2: 'wolf' }, { 1: 'wolf', 2: 'player' }, [1, 2], metadata);
assert.deepEqual(metadata[1].injects_into, ['player']);
for (const saved of Object.values(JSON.parse(h.project.animationdirector_animdef_definitions))) {
assert.equal(saved.actors[0].label, 'wolf');
assert.equal(saved.actors[0].injects_into, 'player');
assert.equal(saved.actors[0].prop_right, 'minecraft:stick');
assert.equal(saved.position_anchor_actor, 'player');
assert.equal(saved.stages[0].props.player, null);
assert.deepEqual(saved.stages[0].props.wolf, { prop_left: null });
}
const previews = JSON.parse(h.project.animationdirector_prop_previews);
assert.equal(previews[JSON.stringify(['first', 'player', 'right', 1])].actor, 'player');
});
test('own stage remains explicit and hidden loop controls do not block non-looping stages', () => {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
assert.equal(result.stub_stage_2_use_stage, 'own');
result.stub_stage_2_midpoint_offset = 'not applicable';
result.stub_stage_2_duration_multiplier = 'not applicable';
const stage = h.helpers.readStageDefinitionFromForm(result, h.analysis.stages[1], true);
assert.equal(stage.use_stage, undefined);
assert.equal(stage.cycle_midpoint_offset_seconds, undefined);
assert.equal(stage.stage_duration_multiplier, undefined);
});
test('obsolete injection targets and position anchors remain visible for correction', () => {
const h = createHarness();
h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: {
actors: [{ label: 'player', entity_types: ['minecraft:player'], injector: 'V', injects_into: 'removed' }],
position_anchor_actor: 'removed', stages: [{ stage: 2, use_stage: 99 }],
} });
const dialog = h.open();
assert.match(dialog.form.stub_actor_1_injects_into.options.removed, /unavailable/);
assert.match(dialog.form.position_anchor_actor.options.removed, /unavailable/);
assert.match(dialog.form.stub_stage_2_use_stage.options['99'], /unavailable/);
});
test('selecting an injector adds an editable male tag without enforcing it on export', () => {
for (const role of ['V', 'A', 'M']) {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form), updates = [];
@@ -123,6 +167,19 @@ test('Save retains definition fields in the project without exporting and restor
assert.equal(h.exports[0].display_name, result.display_name);
});
test('cycle seconds always follow the current stage lengths', () => {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
result.stub_stage_1_cycle_seconds = 99;
result.stub_stage_2_cycle_seconds = 88;
h.setFormResult(result);
assert.equal(dialog.onConfirm(result), true);
assert.deepEqual(h.exports[0].stages.map((stage) => stage.cycle_seconds), [1, 2]);
const reopened = h.open();
assert.equal(reopened.form.stub_stage_1_cycle_seconds.value, 1);
assert.equal(reopened.form.stub_stage_2_cycle_seconds.value, 2);
});
test('invalid Save leaves previously saved fields intact', () => {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
result.display_name = 'Original';
@@ -265,7 +322,7 @@ test('groups advanced matching and offers only other actors as injection targets
'', 'manualpeak', 'defeated', 'stuck', 'tease', 'birth', 'fillbottle', 'other',
]);
assert.equal(form.stub_stage_2_use_stage.type, 'select');
assert.deepEqual(Object.keys(form.stub_stage_2_use_stage.options), ['1', '2', '']);
assert.deepEqual(Object.keys(form.stub_stage_2_use_stage.options), ['1', '2', 'own']);
});
test('keeps minimal definitions minimal and supports specialized behavior', () => {
+9 -3
View File
@@ -34,7 +34,12 @@ function harness() {
class Cube {
static all = [];
static preview_controller = {
on(names, listener) { for (const name of names.split(' ')) previewEvents.set(name, listener); },
on(names, listener) {
for (const name of names.split(' ')) {
const previous = previewEvents.get(name);
previewEvents.set(name, (event) => { previous?.(event); listener(event); });
}
},
};
constructor(data) {
Object.assign(this, data, { uuid: `cube-${++sequence}`, mesh: {},
@@ -46,6 +51,7 @@ function harness() {
remove() { Cube.all = Cube.all.filter((cube) => cube !== this); }
}
const context = {
localStorage: { getItem() { return null; }, setItem() {} },
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
Toggle: class { constructor(id, options) { Object.assign(this, options, { id, value: false }); } },
@@ -137,7 +143,7 @@ test('desktop-only tools are visible in desktop Blockbench and hidden in browser
const h = harness(); h.context.registration.onload();
const actions = h.menus.flatMap((menu) => menu.children).filter((action) => action && typeof action !== 'string');
const ids = ['animationdirector_automatic_import', 'animationdirector_pack_folders', 'animationdirector_item_props',
'animationdirector_sound_library', 'animationdirector_create_jem_model'];
'animationdirector_sound_library', 'animationdirector_particle_library', 'animationdirector_create_jem_model'];
for (const id of ids) {
const action = actions.find((entry) => entry.id === id);
assert.ok(action, `${id} is registered`);
@@ -155,7 +161,7 @@ test('animation menu orders import and creation actions and has no deprecated sp
assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [
'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
'_', 'Actor Preferences', 'Replace Actor Model',
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library',
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'NoN Particle Library', 'Particle Previews',
'_', 'Animation Definition', 'Export Animations',
]);
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
+173
View File
@@ -0,0 +1,173 @@
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, normalizeGeckoExportName };\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,
normalizeName: context.variantTest.normalizeGeckoExportName });
}
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('GeckoLib export filenames strip repeated geo and project extensions', () => {
const normalize = variantBuilder().normalizeName;
for (const name of ['horse', 'horse.geo', 'horse.geo.geo', 'horse.geo.json', 'horse.geo.bbench', 'horse.geo.bbmodel']) {
assert.equal(normalize(name), 'horse');
}
assert.equal(normalize('horse.f.geo.geo.json'), 'horse.f');
assert.equal(normalize('geology'), 'geology');
});
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');
});
+27 -3
View File
@@ -13,13 +13,13 @@ function harness() {
Blockbench: { showQuickMessage(message) { context.message = message; } }, console,
};
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);
return context;
}
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();
} };
}
@@ -35,7 +35,8 @@ function animator() {
test('previous pose button stays visible and explains why it cannot be used', () => {
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.refreshPreviousStagePoseButton();
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();
assert.match(label.textContent, /no previous stage/);
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', () => {
@@ -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.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);
});
+28 -1
View File
@@ -11,7 +11,7 @@ function harness() {
.replace(' Plugin.register(PLUGIN_ID, {', `
globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds,
applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY,
installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper };
installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper, refreshSoundLibraryPanel };
Plugin.register(PLUGIN_ID, {`);
class EffectAnimator {
constructor(animation) { this.animation = animation; this.sound = []; }
@@ -69,6 +69,33 @@ function assets(t, json, files = {}) {
return root;
}
test('keyframe sound library button only appears for sound selections', () => {
const h = harness(), elements = new Map();
const id = 'animationdirector_sound_library_button';
elements.set('panel_keyframe', { appendChild(button) { elements.set(button.id, button); } });
h.context.Blockbench = { isWeb: false };
h.context.document = {
getElementById(id) { return elements.get(id); },
createElement() { return { style: {}, addEventListener() {}, remove() { elements.delete(this.id); } }; },
};
for (const channel of ['particle', 'rotation', null, 'sound', 'particle', 'sound']) {
h.context.Timeline.selected = channel ? [{ channel }] : [];
h.refreshSoundLibraryPanel();
assert.equal(elements.has(id), channel === 'sound', `Button visibility for ${channel}`);
}
h.context.Timeline.selected.push({ channel: 'particle' });
h.refreshSoundLibraryPanel();
assert.equal(elements.has(id), false, 'Mixed selections do not show sound-only controls');
h.context.Timeline.selected = [{ channel: 'sound' }];
h.refreshSoundLibraryPanel();
const first = elements.get(id);
h.refreshSoundLibraryPanel();
assert.equal(elements.get(id), first, 'Refreshing does not duplicate the button');
h.context.Blockbench.isWeb = true;
h.refreshSoundLibraryPanel();
assert.equal(elements.has(id), false, 'Desktop-only controls remain hidden in browsers');
});
test('sound library reads the real NoN sound registry, lazily maps semantic cues and caches audio', () => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources'));
+30 -1
View File
@@ -8,7 +8,7 @@ const vm = require('node:vm');
function harness() {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject, applyAllActorTexturePreviews, setProjectActorTextures, setProjectBoneTextureOverrides };\n Plugin.register(PLUGIN_ID, {'
' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject, applyAllActorTexturePreviews, setProjectActorTextures, setProjectBoneTextureOverrides, restoreActorTextureMaterial };\n Plugin.register(PLUGIN_ID, {'
);
class Group {
static all = [];
@@ -69,6 +69,35 @@ test('growing the project UV canvas preserves other actors and converts box UVs'
assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 64, 16], 'Repeated refreshes must not rescale UVs');
});
test('preview material rebuild restores actor overrides without modifying faces or UVs', () => {
const h = harness(), cube = new h.Cube('actor1_body');
const material = {}, replacement = {};
new h.Texture({ name: 'actor.png', material }).add();
h.setProjectActorTextures({ 1: 'actor.png' });
cube.mesh = { material: replacement };
const before = JSON.stringify(cube.faces);
h.restoreActorTextureMaterial({ element: cube });
assert.equal(cube.mesh.material, material);
assert.equal(JSON.stringify(cube.faces), before);
const other = new h.Cube('actor2_body');
other.mesh = { material: replacement };
h.restoreActorTextureMaterial({ element: other });
assert.equal(other.mesh.material, replacement);
});
test('folder overrides take priority when restoring preview materials', () => {
const h = harness(), cube = new h.Cube('actor1_body');
const actorMaterial = {}, folderMaterial = {};
new h.Texture({ name: 'actor.png', material: actorMaterial }).add();
new h.Texture({ name: 'folder.png', material: folderMaterial }).add();
cube.parent = new h.context.Group({ name: 'actor1_details', uuid: 'details' });
cube.mesh = { material: {} };
h.setProjectActorTextures({ 1: 'actor.png' });
h.setProjectBoneTextureOverrides({ details: { uuid: 'details', name: 'actor1_details', texture: 'folder.png' } });
h.restoreActorTextureMaterial({ element: cube });
assert.equal(cube.mesh.material, folderMaterial);
});
test('non-GeckoLib projects keep UVs, canvas dimensions and box UV mode while still assigning textures', () => {
for (const format of ['optifine_entity', 'optifine_part', 'bedrock', 'free', 'java_block']) {
const h = harness(), cube = new h.Cube('actor1_body', true);