Compare commits
8
Commits
41c9cda88f
...
9241b35291
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9241b35291 | ||
|
|
8aa71244b6 | ||
|
|
3e26c7a657 | ||
|
|
c1c94a7e16 | ||
|
|
a249f1bbdd | ||
|
|
e7bb16df64 | ||
|
|
2780f8aebb | ||
|
|
13b3c9dd77 |
+698
-40
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
@@ -265,7 +309,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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user