Compare commits
41
Commits
63f79d9002
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6bedaf41e | ||
|
|
9241b35291 | ||
|
|
8aa71244b6 | ||
|
|
3e26c7a657 | ||
|
|
c1c94a7e16 | ||
|
|
a249f1bbdd | ||
|
|
e7bb16df64 | ||
|
|
2780f8aebb | ||
|
|
13b3c9dd77 | ||
|
|
41c9cda88f | ||
|
|
312dbf7e92 | ||
|
|
7f1c7d70f8 | ||
|
|
5cb0ba631e | ||
|
|
f7c0c38ec2 | ||
|
|
b61142f9bd | ||
|
|
297dcd82da | ||
|
|
4735c33e6e | ||
|
|
9920f1da4c | ||
|
|
94b3145254 | ||
|
|
3489315611 | ||
|
|
53a5c47549 | ||
|
|
61df6bb0d3 | ||
|
|
474f244adb | ||
|
|
55d5593804 | ||
|
|
6d4094e7c4 | ||
|
|
0bceb63671 | ||
|
|
8cfdf0ecec | ||
|
|
d04f26465f | ||
|
|
37e30e805f | ||
|
|
2bae0c3946 | ||
|
|
3206b1b3f1 | ||
|
|
27a42a076b | ||
|
|
733b62194a | ||
|
|
8d1346986b | ||
|
|
2486c3cb63 | ||
|
|
3505efed8c | ||
|
|
26f7aaecc1 | ||
|
|
8764af4c1a | ||
|
|
f1f597dcca | ||
|
|
a5e72e1d74 | ||
|
|
9d41f962eb |
+4718
-781
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
# AnimationDirector
|
||||
|
||||
Blockbench tools for creating multi-actor animations and animation definitions for Needs of Nature.
|
||||
|
||||
## Installation And Requirements
|
||||
|
||||
- Install `AnimationDirector.js` using Blockbench's **Plugins > Load Plugin from File**. No separate logo or asset files are required.
|
||||
- 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 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
|
||||
|
||||
1. Use **Tools > AnimationDirector Anims > Import Whole Animation** to select an animdef or `.animation.json` from an extracted pack. A new GeckoLib project is created after reviewing actors and models.
|
||||
2. Alternatively, import actors and animations manually. Actor bones use `actorN_` prefixes to prevent name collisions.
|
||||
3. Use **Actor Preferences** to set actor names, preview textures, and matching information.
|
||||
4. Configure **Block Requirements** with live required-block/clearance previews, and **Item Props** with locally available assets or a PNG texture.
|
||||
5. Use **Animation Definition** to save generator fields in the project or export an animdef. Imported definitions replace the saved fields for that animation.
|
||||
6. Use **Export Animations** to export all physical stages and actor clips for an animation ID. Keep the Blockbench project as the editable source.
|
||||
|
||||
## 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 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.
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
node --check AnimationDirector.js
|
||||
node -e "for(const f of require('node:fs').readdirSync('test').filter(f=>f.endsWith('.test.js')))require('./test/'+f)"
|
||||
```
|
||||
|
||||
The tests use Blockbench API doubles. Before release, also check real Blockbench imports, Undo/Redo, JEM texture export, mixed atlas sizes, sound playback, and plugin unload/reload.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,375 @@
|
||||
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 createHarness() {
|
||||
const file = path.join(__dirname, '..', 'AnimationDirector.js');
|
||||
const source = fs.readFileSync(file, 'utf8').replace(
|
||||
' Plugin.register(PLUGIN_ID, {',
|
||||
' globalThis.adTest = { showAnimationDefinitionReviewDialog, createInjectorGenderDefaultHandler, remapActorReferences, readStageDefinitionFromForm };\n Plugin.register(PLUGIN_ID, {'
|
||||
);
|
||||
const dialogs = [];
|
||||
const exports = [];
|
||||
const errors = [];
|
||||
let formResult;
|
||||
const project = { multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
|
||||
const context = {
|
||||
Project: project,
|
||||
Plugin: { register() {} },
|
||||
Dialog: class {
|
||||
constructor(options) { this.options = options; dialogs.push(options); }
|
||||
show() {}
|
||||
getFormResult() { return formResult ?? defaults(this.options.form); }
|
||||
},
|
||||
Blockbench: {
|
||||
export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
|
||||
showMessageBox(options) { errors.push(options.message); },
|
||||
showQuickMessage() {},
|
||||
},
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(source, context, { filename: file });
|
||||
const group = { id: 'test_animation' };
|
||||
const analysis = {
|
||||
actorIndices: [1, 2],
|
||||
actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
|
||||
stages: [
|
||||
{ stageNumber: 1, name: 'test_animation.p1', loop: true, cycleSeconds: 1 },
|
||||
{ stageNumber: 2, name: 'test_animation.p2', loop: false, cycleSeconds: 2 },
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
function open() {
|
||||
context.adTest.showAnimationDefinitionReviewDialog(group, analysis);
|
||||
return dialogs.at(-1);
|
||||
}
|
||||
return { open, exports, errors, project, analysis, helpers: context.adTest, createInjectorGenderDefaultHandler: context.adTest.createInjectorGenderDefaultHandler,
|
||||
setFormResult(result) { formResult = result; } };
|
||||
}
|
||||
|
||||
function defaults(form) {
|
||||
return Object.fromEntries(Object.entries(form)
|
||||
.filter(([, field]) => field && typeof field === 'object' && 'value' in field)
|
||||
.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 = [];
|
||||
const instance = { form: { setValues(values, notify) { updates.push(values); assert.equal(notify, false); } } };
|
||||
result.stub_actor_1_actor_tags = 'custom.tag';
|
||||
result.stub_actor_1_injector = role;
|
||||
dialog.onFormChange.call(instance, result);
|
||||
assert.equal(result.stub_actor_1_actor_tags, 'custom.tag, gender.male');
|
||||
assert.equal(updates.length, 1);
|
||||
result.stub_actor_1_actor_tags = 'custom.tag';
|
||||
dialog.onFormChange.call(instance, result);
|
||||
result.display_name = 'Unrelated edit';
|
||||
dialog.onFormChange.call(instance, result);
|
||||
assert.equal(updates.length, 1, 'Manual removal survives unrelated changes');
|
||||
result.stub_actor_2_entity_types = 'minecraft:wolf';
|
||||
dialog.onConfirm(result);
|
||||
assert.equal(h.errors.length, 0);
|
||||
assert.equal(h.exports[0].actors[0].injector, role);
|
||||
assert.deepEqual(h.exports[0].actors[0].actor_tags, ['custom.tag']);
|
||||
}
|
||||
});
|
||||
|
||||
test('injector defaults preserve loaded roles, existing tags, and actor preferences edits', () => {
|
||||
const h = createHarness();
|
||||
const form = {
|
||||
preferences_animdef_1_injector: { value: 'V' },
|
||||
preferences_animdef_2_injector: { value: '' },
|
||||
};
|
||||
const update = h.createInjectorGenderDefaultHandler(form), writes = [];
|
||||
const dialog = { form: { setValues(values) { writes.push(values); } } };
|
||||
const result = { preferences_animdef_1_injector: 'V', preferences_animdef_1_actor_tags: 'custom.tag',
|
||||
preferences_animdef_2_injector: '', preferences_animdef_2_actor_tags: 'gender.male, other.tag' };
|
||||
update(dialog, result);
|
||||
assert.equal(writes.length, 0, 'Opening an imported/saved role does not add a tag');
|
||||
result.preferences_animdef_2_injector = 'A'; update(dialog, result);
|
||||
assert.equal(writes.length, 0, 'Existing male tags are not duplicated');
|
||||
result.preferences_animdef_2_injector = ''; update(dialog, result);
|
||||
assert.equal(result.preferences_animdef_2_actor_tags, 'gender.male, other.tag', 'Deselecting a role does not remove explicit tags');
|
||||
result.preferences_animdef_2_actor_tags = 'other.tag';
|
||||
result.preferences_animdef_2_injector = 'M'; update(dialog, result);
|
||||
assert.equal(result.preferences_animdef_2_actor_tags, 'other.tag, gender.male');
|
||||
assert.equal(result.preferences_animdef_1_actor_tags, 'custom.tag', 'Only the changed actor is affected');
|
||||
});
|
||||
|
||||
test('Save retains definition fields in the project without exporting and restores them on reopen', () => {
|
||||
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
|
||||
assert.deepEqual(Array.from(dialog.buttons), ['Cancel', 'Save', 'Create Animdef']);
|
||||
assert.equal(dialog.confirmIndex, 2);
|
||||
result.display_name = 'Saved definition';
|
||||
result.content_tags = 'saved, tags';
|
||||
result.weight = '3';
|
||||
result.stub_stage_1_peaked = true;
|
||||
result.stub_stage_2_peaked = false;
|
||||
h.setFormResult(result);
|
||||
assert.equal(dialog.onButton(1), false);
|
||||
assert.equal(h.exports.length, 0);
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(h.project.saved, false);
|
||||
const reopened = h.open();
|
||||
assert.equal(reopened.form.display_name.value, result.display_name);
|
||||
assert.equal(reopened.form.content_tags.value, result.content_tags);
|
||||
assert.equal(reopened.form.weight.value, 3);
|
||||
assert.equal(reopened.form.stub_stage_1_peaked.value, true);
|
||||
assert.equal(reopened.form.stub_stage_2_peaked.value, false);
|
||||
assert.equal(reopened.onConfirm(defaults(reopened.form)), true);
|
||||
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';
|
||||
h.setFormResult(result); dialog.onButton(1);
|
||||
const original = h.project.animationdirector_animdef_definitions;
|
||||
h.setFormResult({ ...result, display_name: 'Invalid', weight: '0' });
|
||||
dialog.onButton(1);
|
||||
assert.equal(h.project.animationdirector_animdef_definitions, original);
|
||||
assert.equal(h.exports.length, 0);
|
||||
assert.equal(h.errors.length, 1);
|
||||
});
|
||||
|
||||
test('metadata-only playback stages remain editable and survive definition export', () => {
|
||||
const h = createHarness();
|
||||
h.analysis.stages = h.analysis.stages.slice(0, 1);
|
||||
h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: {
|
||||
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
|
||||
stages: [{ stage: 1, loop: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }],
|
||||
} });
|
||||
const dialog = h.open();
|
||||
assert.equal(dialog.form.stub_stage_2_use_stage.value, '1');
|
||||
dialog.onConfirm(defaults(dialog.form));
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(h.exports[0].stages.length, 2);
|
||||
assert.equal(h.exports[0].stages[1].use_stage, 1);
|
||||
assert.equal(h.exports[0].stages[1].speed, 0.8);
|
||||
assert.equal(h.analysis.stages.length, 1);
|
||||
});
|
||||
|
||||
test('prop tool metadata survives generator export including reused-stage overrides', () => {
|
||||
const h = createHarness();
|
||||
h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: {
|
||||
actors: [{label:'player',entity_types:['minecraft:player'],prop_right:'minecraft:carrot'},
|
||||
{label:'wolf',entity_types:['minecraft:wolf'],prop_floor:'minecraft:stone'}],
|
||||
stages: [{stage:1,props:{player:{prop_left:'minecraft:stick'}}},
|
||||
{stage:2,use_stage:1,props:{player:{prop_right:null},wolf:null}}],
|
||||
} });
|
||||
const dialog=h.open(); dialog.onConfirm(defaults(dialog.form));
|
||||
assert.deepEqual(h.errors,[]);
|
||||
const def=h.exports[0];
|
||||
assert.equal(def.actors[0].prop_right,'minecraft:carrot');
|
||||
assert.equal(def.actors[1].prop_floor,'minecraft:stone');
|
||||
assert.equal(def.stages[0].props.player.prop_left,'minecraft:stick');
|
||||
assert.equal(def.stages[1].props.player.prop_right,null);
|
||||
assert.equal(def.stages[1].props.wolf,null);
|
||||
});
|
||||
|
||||
test('generates extended animdef fields and restores them on reopen', () => {
|
||||
const harness = createHarness();
|
||||
const dialog = harness.open();
|
||||
const result = defaults(dialog.form);
|
||||
result.stub_actor_1_injector = 'V';
|
||||
result.stub_actor_1_receiver = true;
|
||||
result.stub_actor_1_injects_into = ['wolf'];
|
||||
result.stub_actor_1_held_items = 'minecraft:carrot, #minecraft:swords';
|
||||
result.stub_actor_1_held_hand = 'either';
|
||||
result.stub_actor_1_held_prop_slot = 'right';
|
||||
result.stub_actor_2_receiver = true;
|
||||
result.system_tags = 'manualpeak';
|
||||
result.content_tags = 'test, tag';
|
||||
result.required_union_tags = 'example.tag';
|
||||
result.join_after = 'creator:previous';
|
||||
result.match_content_on_join = true;
|
||||
result.weight = '2.5';
|
||||
result.attack_eligible = false;
|
||||
result.manual_peak_priority = '10';
|
||||
result.liquid_gain_multiplier = '0.5';
|
||||
result.base_speed = '1.25';
|
||||
result.water = 'surface';
|
||||
result.position_anchor_actor = 'wolf';
|
||||
result.camera_torso_tracking = 'continuous';
|
||||
result.camera_block_collision = 'ignore';
|
||||
result.stub_stage_2_use_stage = '1';
|
||||
|
||||
assert.equal(dialog.onConfirm(result), true);
|
||||
assert.equal(harness.errors.length, 0);
|
||||
assert.equal(harness.exports.length, 1);
|
||||
const definition = harness.exports[0];
|
||||
assert.equal(definition.weight, 2.5);
|
||||
assert.equal(definition.attack_eligible, false);
|
||||
assert.deepEqual(Array.from(definition.system_tags), ['manualpeak']);
|
||||
assert.deepEqual(Array.from(definition.content_tags), ['test', 'tag']);
|
||||
assert.deepEqual(Array.from(definition.join_after), ['creator:previous']);
|
||||
assert.equal(definition.manual_peak.priority, 10);
|
||||
assert.equal(definition.actors[0].injects_into, 'wolf');
|
||||
assert.equal(definition.actors[0].receiver, true);
|
||||
assert.equal(definition.actors[0].held_item.prop_slot, 'right');
|
||||
assert.equal('prop_floor' in definition.actors[1], false);
|
||||
assert.equal(definition.stages[1].use_stage, 1);
|
||||
assert.equal(definition.camera.torso_tracking, 'continuous');
|
||||
assert.equal(definition.water, 'surface');
|
||||
assert.equal(definition.position_anchor_actor, 'wolf');
|
||||
assert.equal('stage_seconds' in definition, false);
|
||||
assert.equal('escapable' in definition, false);
|
||||
|
||||
const reopened = harness.open();
|
||||
assert.equal(reopened.form.weight.value, 2.5);
|
||||
assert.equal(reopened.form.matching_advanced.value, true);
|
||||
assert.equal(reopened.form.system_tags.value, 'manualpeak');
|
||||
assert.equal(reopened.form.stub_actor_1_injector.value, 'V');
|
||||
assert.deepEqual(Array.from(reopened.form.stub_actor_1_injects_into.value), ['wolf']);
|
||||
assert.equal(reopened.form.stub_stage_2_use_stage.value, '1');
|
||||
});
|
||||
|
||||
test('rejects nonexistent injection targets and playback stages', () => {
|
||||
const harness = createHarness();
|
||||
const dialog = harness.open();
|
||||
const result = defaults(dialog.form);
|
||||
result.stub_actor_1_injector = 'V';
|
||||
result.stub_actor_1_injects_into = ['missing'];
|
||||
assert.equal(dialog.onConfirm(result), false);
|
||||
assert.match(harness.errors.at(-1), /invalid injection target/);
|
||||
result.stub_actor_1_injects_into = ['wolf'];
|
||||
result.stub_stage_2_use_stage = '99';
|
||||
assert.equal(dialog.onConfirm(result), false);
|
||||
assert.match(harness.errors.at(-1), /missing playback stage/);
|
||||
assert.equal(harness.exports.length, 0);
|
||||
});
|
||||
|
||||
test('groups advanced matching and offers only other actors as injection targets', () => {
|
||||
const form = createHarness().open().form;
|
||||
const keys = Object.keys(form);
|
||||
assert.equal(form.matching_advanced.value, false);
|
||||
for (const key of ['required_union_tags', 'join_after', 'match_content_on_join', 'weight']) {
|
||||
assert.equal(form[key].condition({ matching_advanced: false }), false);
|
||||
assert.equal(form[key].condition({ matching_advanced: true }), true);
|
||||
assert.ok(keys.indexOf(key) > keys.indexOf('matching_advanced'));
|
||||
}
|
||||
assert.equal(form.stub_actor_1_receiver.label, 'Receiver');
|
||||
assert.equal(form.stub_actor_1_injects_into.type, 'multi_select');
|
||||
assert.deepEqual(Object.keys(form.stub_actor_1_injects_into.options), ['wolf']);
|
||||
assert.deepEqual(Object.keys(form.stub_actor_2_injects_into.options), ['player']);
|
||||
assert.equal(keys.indexOf('stub_actor_1_injects_into'), keys.indexOf('stub_actor_1_injector') + 1);
|
||||
assert.equal(keys.some((key) => /_prop_(left|right|floor)$/.test(key)), false);
|
||||
assert.equal('default_stage_seconds' in form, false);
|
||||
assert.equal('default_escapable' in form, false);
|
||||
assert.equal('stub_behavior' in form, false);
|
||||
assert.equal(form.system_tags.type, 'select');
|
||||
assert.deepEqual(Object.keys(form.system_tags.options), [
|
||||
'', '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', 'own']);
|
||||
});
|
||||
|
||||
test('keeps minimal definitions minimal and supports specialized behavior', () => {
|
||||
const harness = createHarness();
|
||||
const dialog = harness.open();
|
||||
const minimal = defaults(dialog.form);
|
||||
assert.equal(dialog.onConfirm(minimal), true);
|
||||
const first = harness.exports.at(-1);
|
||||
for (const key of ['weight', 'camera', 'water', 'manual_peak', 'block_requirements']) {
|
||||
assert.equal(key in first, false, `${key} should be omitted by default`);
|
||||
}
|
||||
|
||||
const teaseDialog = harness.open();
|
||||
const tease = defaults(teaseDialog.form);
|
||||
tease.system_tags = 'tease';
|
||||
tease.stub_tease_multiplier = 3;
|
||||
assert.equal(teaseDialog.onConfirm(tease), true);
|
||||
const second = harness.exports.at(-1);
|
||||
assert.deepEqual(Array.from(second.system_tags), ['tease']);
|
||||
assert.equal(second.tease.near_animation_energy_multiplier, 3);
|
||||
|
||||
const stuckDialog = harness.open();
|
||||
const stuck = defaults(stuckDialog.form);
|
||||
stuck.system_tags = 'stuck';
|
||||
stuck.content_tags = 'stuckfence';
|
||||
assert.equal(stuckDialog.onConfirm(stuck), true);
|
||||
const third = harness.exports.at(-1);
|
||||
assert.deepEqual(Array.from(third.system_tags), ['stuck']);
|
||||
assert.deepEqual(Array.from(third.content_tags), ['stuckfence']);
|
||||
assert.equal(third.stuck.damage_behavior, 'stop_on_damage');
|
||||
});
|
||||
|
||||
test('exports one custom behavior through Other', () => {
|
||||
const harness = createHarness();
|
||||
const dialog = harness.open();
|
||||
const result = defaults(dialog.form);
|
||||
result.system_tags = 'other';
|
||||
result.other_system_tags = 'custom_tag';
|
||||
assert.equal(dialog.onConfirm(result), true);
|
||||
const definition = harness.exports.at(-1);
|
||||
assert.deepEqual(Array.from(definition.system_tags), ['custom_tag']);
|
||||
assert.equal('tease' in definition, false);
|
||||
assert.equal('stuck' in definition, false);
|
||||
|
||||
const reopened = harness.open();
|
||||
assert.equal(reopened.form.system_tags.value, 'other');
|
||||
assert.equal(reopened.form.other_system_tags.value, 'custom_tag');
|
||||
assert.equal(reopened.form.other_system_tags.condition({ system_tags: 'other' }), true);
|
||||
assert.equal(reopened.form.other_system_tags.condition({ system_tags: '' }), false);
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const plugin = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||
const io = { fs, path };
|
||||
|
||||
function harness(capture = false) {
|
||||
const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', `
|
||||
globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry,
|
||||
resolveImportModel, importModelCandidates, companionImportFiles, definitionActorKeys, defaultImportClipRoles,
|
||||
validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport, resolveDefinitionImportStages,
|
||||
createImportAssetLookup, importGeometryIntoCurrentProject };
|
||||
Plugin.register(PLUGIN_ID, {`).replace(' function commitAutomaticImport(plan) {',
|
||||
` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`);
|
||||
const storage = new Map(), dialogs = [], errors = [], undo = [], messages = [];
|
||||
let sequence = 0;
|
||||
class Group {
|
||||
static all = [];
|
||||
constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); }
|
||||
init() { Group.all.push(this); }
|
||||
addTo(parent) { this.parent = parent; if (typeof parent === 'object') parent.children.push(this); }
|
||||
}
|
||||
class Texture {
|
||||
static all = [];
|
||||
constructor(data) { Object.assign(this, data); }
|
||||
fromPath(file) { this.path = file; this.name = path.basename(file); return this; }
|
||||
fromDataURL(url) { this.source = url; return this; }
|
||||
add(undo) { assert.equal(undo, false); Texture.all.push(this); }
|
||||
}
|
||||
const context = {
|
||||
Buffer, Blob, URL: { createObjectURL() { return 'blob:import-preview'; }, revokeObjectURL() {} }, Timeline: { animation: null },
|
||||
Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] },
|
||||
Plugin: { register() {} }, requireNativeModule: require,
|
||||
localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) },
|
||||
Formats: { geckolib_model: { id: 'geckolib_model' } },
|
||||
newProject(format) {
|
||||
assert.equal(format.id, 'geckolib_model');
|
||||
context.Format = format;
|
||||
context.Project = { uuid: 'new-project' };
|
||||
return true;
|
||||
},
|
||||
Undo: {
|
||||
initEdit(aspects) { undo.push({ type: 'begin', aspects }); },
|
||||
finishEdit(action, aspects) { undo.push({ type: 'finish', action, aspects }); },
|
||||
cancelEdit() { undo.push({ type: 'cancel' }); Group.all = []; Texture.all = []; context.Animation.all = []; },
|
||||
},
|
||||
Animator: {
|
||||
loadFile(file, names) {
|
||||
context.loadedAnimation = JSON.parse(file.content);
|
||||
context.Animation.all = names.map((name) => ({ name, select() {}, animators: { effects: { sound:
|
||||
Object.entries(context.loadedAnimation.animations[name]?.sound_effects || {}).map(([time, cues]) => ({
|
||||
time: Number(time), data_points: Array.isArray(cues) ? cues : [cues],
|
||||
})) } } }));
|
||||
},
|
||||
},
|
||||
Animation: { all: [] }, Canvas: { updateAll() {}, updateView() {} },
|
||||
setTimeout() { return 1; }, clearTimeout() {}, console,
|
||||
Blockbench: { showMessageBox(options) { errors.push(options.message); }, showQuickMessage(message) { messages.push(message); } },
|
||||
Filesystem: { importFile(options, callback) { callback(context.selectedFile ? [context.selectedFile] : []); } },
|
||||
Dialog: class {
|
||||
constructor(options) { this.options = options; }
|
||||
show() {
|
||||
dialogs.push(this.options);
|
||||
const result = Object.fromEntries(Object.entries(this.options.form).filter(([, value]) => 'value' in value)
|
||||
.map(([key, value]) => [key, value.value]));
|
||||
const choice = context.answer?.(this.options, result) ?? result;
|
||||
if (choice === false) this.options.onCancel();
|
||||
else this.options.onConfirm(choice);
|
||||
}
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(source, context);
|
||||
return { ...context.api, context, storage, dialogs, errors, undo, messages };
|
||||
}
|
||||
|
||||
function fixtures(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'animationdirector-import-'));
|
||||
t.after(() => {
|
||||
assert.equal(path.dirname(root), fs.realpathSync(os.tmpdir()));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const write = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, typeof data === 'string' ? data : JSON.stringify(data));
|
||||
return file;
|
||||
};
|
||||
const makePack = (name, metadata = {}) => {
|
||||
const folder = path.join(root, name);
|
||||
write(path.join(folder, 'pack.mcmeta'), { animationframework: { id: `test:${name}`, ...metadata } });
|
||||
return harness().readImportPack(folder, io);
|
||||
};
|
||||
const model = (pack, id, description = {}) => {
|
||||
const [namespace, resource] = id.split(':');
|
||||
return write(path.join(pack.root, 'assets', namespace, 'geckolib', 'models', `${resource}.geo.json`),
|
||||
{ ...description, 'minecraft:geometry': [{ description: { texture_width: 64, texture_height: 64 }, bones: [{ name: 'body' }] }] });
|
||||
};
|
||||
return { root, write, makePack, model };
|
||||
}
|
||||
|
||||
function scopedFilesystem() {
|
||||
return Object.fromEntries(['existsSync', 'readFileSync', 'readdirSync', 'statSync']
|
||||
.map((method) => [method, fs[method].bind(fs)]));
|
||||
}
|
||||
|
||||
test('Blockbench scoped filesystem without realpathSync supports folder registration and resource lookup', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path;
|
||||
const restricted = h.desktopFiles();
|
||||
assert.equal(restricted.fs.realpathSync, undefined);
|
||||
const model = f.model(pack, 'test:source/entity/wolf.m');
|
||||
const source = h.readImportPack(pack.root, restricted);
|
||||
assert.equal(source.root, path.resolve(pack.root));
|
||||
assert.equal(h.findImportPack(model, restricted).root, source.root);
|
||||
assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', restricted).file, model);
|
||||
assert.equal(h.importPackRegistry(source, [source.root], restricted).warnings.length, 0);
|
||||
assert.throws(() => h.insidePack(source.root, '../outside.json', restricted), /escapes/);
|
||||
});
|
||||
|
||||
test('scoped filesystem fallback rejects linked paths instead of bypassing containment checks', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
const link = path.join(pack.root, 'linked');
|
||||
const target = path.join(f.root, 'outside');
|
||||
fs.mkdirSync(target);
|
||||
fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
assert.throws(() => h.importFilePath(link, { fs: scopedFilesystem(), path }), /Linked paths/);
|
||||
});
|
||||
|
||||
test('denied filesystem permission reports an actionable message', () => {
|
||||
const h = harness();
|
||||
h.context.requireNativeModule = (name) => name === 'fs' ? undefined : path;
|
||||
assert.throws(() => h.desktopFiles(), /Allow filesystem access/);
|
||||
});
|
||||
|
||||
test('model lookup respects local/import/shared priority per gender candidate, not per provider', (t) => {
|
||||
const f = fixtures(t), h = harness();
|
||||
const source = f.makePack('source', { model_imports: [{ pack: 'test:provider', entities: ['minecraft:wolf'] }] });
|
||||
const provider = f.makePack('provider');
|
||||
f.model(source, 'test:source/entity/wolf');
|
||||
const imported = f.model(provider, 'test:provider/entity/wolf.m');
|
||||
const shared = f.model(provider, 'animationframework:entity/wolf.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, imported);
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Imported test:provider');
|
||||
f.model(source, 'test:source/entity/wolf.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Local');
|
||||
source.metadata.model_imports = [];
|
||||
fs.unlinkSync(path.join(source.root, 'assets/test/geckolib/models/source/entity/wolf.m.geo.json'));
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, shared);
|
||||
});
|
||||
|
||||
test('imports are entity-specific and non-transitive; shared overrides follow folder order', (t) => {
|
||||
const f = fixtures(t), h = harness();
|
||||
const source = f.makePack('source', { model_imports: [{ pack: 'test:b', entities: ['minecraft:wolf'] }] });
|
||||
const b = f.makePack('b', { model_imports: [{ pack: 'test:c', entities: ['minecraft:wolf'] }] });
|
||||
const c = f.makePack('c');
|
||||
f.model(c, 'test:c/entity/wolf.m');
|
||||
f.model(b, 'test:b/entity/horse.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io), null);
|
||||
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:horse', 'm', '', io), null);
|
||||
const top = f.model(b, 'animationframework:entity/wolf.m');
|
||||
f.model(c, 'animationframework:entity/wolf.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io).file, top);
|
||||
});
|
||||
|
||||
test('sparse default revisions resolve oldest applicable model before current shared models', (t) => {
|
||||
const f = fixtures(t), h = harness();
|
||||
const source = f.makePack('source');
|
||||
const defaults = f.makePack('defaults', { id: 'needsofnature:default_pack', default_model_revision: 4 });
|
||||
const old = f.model(defaults, 'needsofnature:default_pack_compat/revision_2/entity/horse.m');
|
||||
const later = f.model(defaults, 'needsofnature:default_pack_compat/revision_3/entity/horse.m');
|
||||
const current = f.model(defaults, 'animationframework:entity/horse.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, old);
|
||||
source.metadata.default_model_revision = 3;
|
||||
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, later);
|
||||
source.metadata.default_model_revision = 4;
|
||||
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, current);
|
||||
});
|
||||
|
||||
test('variant, slim and male+female candidates use NoN fallback order, including legacy shared paths', (t) => {
|
||||
const f = fixtures(t), h = harness(), source = f.makePack('source');
|
||||
assert.deepEqual(Array.from(h.importModelCandidates('minecraft:wolf', 'mf', 'snow')), [
|
||||
'wolf_snow.mf', 'wolf.mf', 'wolf_snow', 'wolf', 'wolf_snow.m', 'wolf.m', 'wolf_snow.f', 'wolf.f',
|
||||
]);
|
||||
assert.equal(h.importModelCandidates('minecraft:player', 'f', '', true)[0], 'player_slim.f');
|
||||
const legacy = f.model(source, 'animationframework:wolf.f');
|
||||
assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', io).file, legacy);
|
||||
});
|
||||
|
||||
test('companion lookup accepts either entry point and detects namespace alternatives without broad scans', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
const def = f.write(path.join(pack.root, 'data/test/afw_animdefs/example.json'), {});
|
||||
const animation = f.write(path.join(pack.root, 'assets/other/animations/afw/example.animation.json'), {});
|
||||
assert.equal(h.findImportPack(def, io).root, pack.root);
|
||||
assert.deepEqual(Array.from(h.companionImportFiles(pack, def, io).animations), [animation]);
|
||||
assert.deepEqual(Array.from(h.companionImportFiles(pack, animation, io).definitions), [def]);
|
||||
assert.throws(() => h.companionImportFiles(pack, path.join(pack.root, 'random.json'), io), /Choose data/);
|
||||
const nested = f.write(path.join(pack.root, 'assets/test/geckolib/animations/afw/nested/nested.animation.json'), {});
|
||||
const nestedDef = f.write(path.join(pack.root, 'data/test/afw_animdefs/nested.json'), {});
|
||||
assert.equal(h.companionImportFiles(pack, nested, io).id, 'nested');
|
||||
assert.deepEqual(Array.from(h.companionImportFiles(pack, nested, io).definitions), [nestedDef]);
|
||||
});
|
||||
|
||||
test('resource paths cannot escape packs, and invalid/cyclic models are rejected before importing', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
assert.throws(() => h.insidePack(pack.root, '../escape.json', io), /escapes/);
|
||||
for (const id of ['test:../file', 'test:a/../../file', 'test:/absolute', 'test:a//file']) assert.equal(h.importResourceId(id), null);
|
||||
assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a', parent: 'b' }, { name: 'b', parent: 'a' }] }] }), /Cyclic/);
|
||||
assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a' }, { name: 'a' }] }] }), /unique/);
|
||||
});
|
||||
|
||||
test('registry preserves explicit priority, inserts only new source first, and reports unavailable folders', (t) => {
|
||||
const f = fixtures(t), h = harness(), source = f.makePack('source'), top = f.makePack('top');
|
||||
assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root, source.root], io).packs, (pack) => pack.root), [top.root, source.root]);
|
||||
assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root], io).packs, (pack) => pack.root), [source.root, top.root]);
|
||||
assert.equal(h.importPackRegistry(source, [path.join(f.root, 'missing')], io).warnings.length, 1);
|
||||
});
|
||||
|
||||
test('actor keys match NoN including required tags, repeated roles and entity variants', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(Array.from(h.definitionActorKeys([
|
||||
{ entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
|
||||
{ entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
|
||||
{ label: 'Custom Role' },
|
||||
{ entity_types: ['minecraft:fox'], actor_tags_any: ['gender.male', 'gender.female'], entity_variant: 'snow', age: 'baby' },
|
||||
])), ['wolf_gender_male', 'wolf_gender_male_2', 'custom_role', 'fox_any_gender_female_gender_male_snow_baby']);
|
||||
});
|
||||
|
||||
test('short clip names are inferred only for unambiguous actors; repeated entities require explicit choices', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(Array.from(h.defaultImportClipRoles([
|
||||
{ entity_types: ['minecraft:player'] }, { entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
|
||||
], ['player', 'wolf'])), ['player', 'wolf']);
|
||||
assert.deepEqual(Array.from(h.defaultImportClipRoles([
|
||||
{ entity_types: ['minecraft:player'], actor_tags: ['gender.male'] },
|
||||
{ entity_types: ['minecraft:player'], actor_tags: ['gender.female'] },
|
||||
], ['player', 'player2'])), ['', '']);
|
||||
});
|
||||
|
||||
test('complete import prepares all stages, actor models, local textures and definition fields without touching the current project', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const def = { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf', 'minecraft:fox'], actor_tags: ['gender.male'],
|
||||
prop_right: 'minecraft:carrot' }],
|
||||
animators: ['Creator'], content_tags: ['example'], stages: [{ stage: 1, non_peak: true }, { stage: 2 }] };
|
||||
const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), def);
|
||||
f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), { animations: {
|
||||
p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } },
|
||||
p2_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } } },
|
||||
} });
|
||||
f.model(source, 'test:source/entity/wolf.m', { afw_texture: 'test:textures/wolf.png' });
|
||||
f.write(path.join(source.root, 'assets/test/textures/wolf.png'), 'local image');
|
||||
f.write(path.join(source.root, 'assets/minecraft/items/carrot.json'), { model: { type: 'minecraft:model', model: 'minecraft:item/carrot' } });
|
||||
f.write(path.join(source.root, 'assets/minecraft/models/item/carrot.json'), {
|
||||
parent: 'minecraft:builtin/generated', textures: { layer0: 'minecraft:item/carrot' } });
|
||||
f.write(path.join(source.root, 'assets/minecraft/textures/item/carrot.png'), 'image');
|
||||
h.context.selectedFile = { path: defFile };
|
||||
h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(h.context.Project.uuid, 'original');
|
||||
const plan = h.context.importedPlan;
|
||||
assert.equal(plan.rows[0].resolved.kind, 'Local');
|
||||
assert.equal(plan.rows[0].entity, 'minecraft:wolf');
|
||||
const importDialog = h.dialogs.find((dialog) => dialog.title.endsWith('Actors & Models'));
|
||||
const actorForm = importDialog.form;
|
||||
assert.equal(actorForm.entity_0.type, 'select');
|
||||
assert.equal(actorForm.entity_0.value, 'minecraft:wolf');
|
||||
assert.equal(actorForm.entity_0.options['minecraft:fox'], 'minecraft:fox');
|
||||
assert.equal(actorForm.gender_0.value, 'm');
|
||||
assert.equal(actorForm.models_section.type, 'info');
|
||||
assert.match(actorForm.source_0.value, /Local:.*wolf\.m\.geo\.json/);
|
||||
assert.equal(actorForm.model_0.type, 'file');
|
||||
assert.equal(h.dialogs.length, 1, 'Import needs only one configuration dialog');
|
||||
let updated;
|
||||
importDialog.onFormChange.call({ form: { setValues(values, rerender) {
|
||||
assert.equal(rerender, false);
|
||||
updated = values;
|
||||
} } }, { entity_0: 'minecraft:fox', gender_0: 'f', slim_0: false });
|
||||
assert.match(updated.source_0, /No automatic model found/);
|
||||
assert.equal(plan.rows[0].textures.size, 1);
|
||||
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1', 'example.p2']);
|
||||
assert.ok(plan.imported.fileObj.animations['example.p2'].bones.actor1_body);
|
||||
assert.equal(plan.definition.actors[0].label, 'wolf');
|
||||
assert.equal(Object.values(plan.propEntries)[0].item, 'minecraft:carrot');
|
||||
assert.equal(plan.definition.stages[1].non_peak, undefined);
|
||||
assert.equal(h.undo.length, 0);
|
||||
assert.ok(h.storage.get('animationdirector_pack_folders').includes(source.root.replace(/\\/g, '\\\\')));
|
||||
});
|
||||
|
||||
test('complete import finds model texture in cached assets and uses its PNG dimensions', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
|
||||
animations: { p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } } },
|
||||
});
|
||||
f.model(source, 'test:source/entity/wolf.m');
|
||||
const cache = path.join(f.root, 'cached-assets');
|
||||
const image = path.join(cache, 'assets/test/textures/entity/wolf.png');
|
||||
fs.mkdirSync(path.dirname(image), { recursive: true });
|
||||
const png = Buffer.alloc(24);
|
||||
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
|
||||
png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20);
|
||||
fs.writeFileSync(image, png);
|
||||
h.storage.set('animationdirector_prop_source', cache);
|
||||
h.context.selectedFile = { path: defFile };
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
const row = h.context.importedPlan.rows[0];
|
||||
assert.equal(row.defaultTexture, 'test:textures/entity/wolf.png');
|
||||
assert.equal(row.textures.get(row.defaultTexture).dimensions.width, 32);
|
||||
assert.equal(row.textures.get(row.defaultTexture).dimensions.height, 64);
|
||||
|
||||
const importing = harness();
|
||||
importing.commitAutomaticImport(h.context.importedPlan);
|
||||
assert.equal(importing.context.Texture.all[0].uv_width, 32);
|
||||
assert.equal(importing.context.Texture.all[0].uv_height, 64);
|
||||
assert.equal(JSON.parse(importing.context.Project.animationdirector_actor_uv_basis)['1'].width, 64,
|
||||
'The actor retains its geometry basis; each textured cube tracks its own normalization');
|
||||
assert.equal(JSON.parse(importing.context.Project.multiactor_actor_textures)['1'], row.defaultTexture);
|
||||
});
|
||||
|
||||
test('alternative vanilla texture paths do not add unused textures unless a bone references them', (t) => {
|
||||
const f = fixtures(t), h = harness(), source = f.makePack('source');
|
||||
const model = f.model(source, 'test:source/entity/silverfish.m');
|
||||
const cache = path.join(f.root, 'cached-assets');
|
||||
const png = Buffer.alloc(24);
|
||||
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
|
||||
png.writeUInt32BE(64, 16); png.writeUInt32BE(32, 20);
|
||||
for (const relative of ['silverfish.png', 'silverfish/silverfish.png']) {
|
||||
const file = path.join(cache, 'assets/minecraft/textures/entity', relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, png);
|
||||
}
|
||||
h.storage.set('animationdirector_prop_source', cache);
|
||||
const json = JSON.parse(fs.readFileSync(model, 'utf8'));
|
||||
const row = { json, geo: json['minecraft:geometry'][0], entity: 'minecraft:silverfish', slim: false,
|
||||
label: 'silverfish', resolved: { file: model } };
|
||||
h.loadImportTextures(row, [source], io);
|
||||
assert.equal(row.defaultTexture, 'minecraft:textures/entity/silverfish/silverfish.png');
|
||||
assert.deepEqual(Array.from(row.textures.keys()), [row.defaultTexture]);
|
||||
|
||||
const plan = { parsed: { animationId: 'example' }, rows: [row], definition: { actors: [{}] },
|
||||
imported: { stageNames: [], fileObj: { animations: {} } }, blockEntry: null };
|
||||
h.commitAutomaticImport(plan);
|
||||
assert.equal(h.context.Texture.all.length, 1);
|
||||
|
||||
row.json.afw_bone_textures = { body: 'minecraft:textures/entity/silverfish.png' };
|
||||
h.loadImportTextures(row, [source], io);
|
||||
assert.deepEqual(Array.from(row.textures.keys()), [row.defaultTexture, 'minecraft:textures/entity/silverfish.png']);
|
||||
});
|
||||
|
||||
test('complete import uses the matching Alex skin as a player-only texture fallback', async (t) => {
|
||||
const f = fixtures(t), source = f.makePack('source');
|
||||
const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'player', entity_types: ['minecraft:player'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
|
||||
animations: { p1_player: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } } },
|
||||
});
|
||||
f.model(source, 'test:source/entity/player.m');
|
||||
f.model(source, 'test:source/entity/player_slim.m');
|
||||
const cache = path.join(f.root, 'cached-assets');
|
||||
const png = Buffer.alloc(24);
|
||||
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
|
||||
png.writeUInt32BE(64, 16); png.writeUInt32BE(64, 20);
|
||||
for (const shape of ['wide', 'slim']) {
|
||||
const image = path.join(cache, `assets/minecraft/textures/entity/player/${shape}/alex.png`);
|
||||
fs.mkdirSync(path.dirname(image), { recursive: true });
|
||||
fs.writeFileSync(image, png);
|
||||
}
|
||||
for (const slim of [false, true]) {
|
||||
const h = harness(true);
|
||||
h.storage.set('animationdirector_prop_source', cache);
|
||||
h.context.selectedFile = { path: defFile };
|
||||
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models')
|
||||
? { ...result, slim_0: slim } : result;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
const row = h.context.importedPlan.rows[0];
|
||||
assert.equal(row.defaultTexture, `minecraft:textures/entity/player/${slim ? 'slim' : 'wide'}/alex.png`);
|
||||
assert.equal(row.textures.get(row.defaultTexture).dimensions.width, 64);
|
||||
}
|
||||
});
|
||||
|
||||
test('definition actors exclude extra clips, their effects and stages containing only unused actors', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: {
|
||||
p1_unused: { animation_length: 99, bones: { discarded: { rotation: [1, 2, 3] } },
|
||||
sound_effects: { '0': { effect: 'discarded_sound' } }, particle_effects: { '0': { effect: 'discarded_particle' } } },
|
||||
p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } },
|
||||
p2_unused: { animation_length: 99, bones: {} },
|
||||
} });
|
||||
f.model(source, 'test:source/entity/wolf.m');
|
||||
h.context.selectedFile = { path: file };
|
||||
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models') ? { ...result, gender_0: 'm' } : result;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
const plan = h.context.importedPlan;
|
||||
assert.equal(plan.rows.length, 1);
|
||||
assert.deepEqual(Array.from(plan.parsed.actorLabels), ['wolf']);
|
||||
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']);
|
||||
const clip = plan.imported.fileObj.animations['example.p1'];
|
||||
assert.equal(clip.animation_length, 1);
|
||||
assert.deepEqual(Object.keys(clip.bones), ['actor1_body']);
|
||||
assert.equal(clip.sound_effects, undefined);
|
||||
assert.equal(clip.particle_effects, undefined);
|
||||
assert.ok(h.messages.some((message) => /Ignored animation actors.*unused/.test(message)));
|
||||
});
|
||||
|
||||
test('a definition actor without a distinct clip still requires a valid mapping', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] },
|
||||
{ label: 'player', entity_types: ['minecraft:player'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
|
||||
animations: { p1_wolf: { animation_length: 1, bones: {} } },
|
||||
});
|
||||
h.context.selectedFile = { path: file };
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.match(h.errors[0], /Each definition actor must be assigned a distinct animation actor/);
|
||||
assert.equal(h.context.importedPlan, undefined);
|
||||
assert.equal(h.context.Project.uuid, 'original');
|
||||
});
|
||||
|
||||
test('automatic import retains use_stage metadata without duplicating editor animations', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }],
|
||||
stages: [{ stage: 1, escapable: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: {
|
||||
p1_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } },
|
||||
sound_effects: { '0': { effect: 'example' } } },
|
||||
} });
|
||||
f.model(source, 'test:source/entity/wolf.m');
|
||||
h.context.selectedFile = { path: file };
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
const plan = h.context.importedPlan;
|
||||
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']);
|
||||
const clips = plan.imported.fileObj.animations;
|
||||
assert.equal(clips['example.p2'], undefined);
|
||||
assert.ok(clips['example.p1'].bones.actor1_body);
|
||||
assert.equal(plan.definition.stages[1].use_stage, 1);
|
||||
assert.equal(plan.definition.stages[1].speed, 0.8);
|
||||
assert.equal(plan.definition.stages[1].non_peak, true);
|
||||
assert.equal(plan.definition.stages[1].escapable, false);
|
||||
});
|
||||
|
||||
test('playback references require declared stages and physical clips, not recursive aliases', () => {
|
||||
const h = harness();
|
||||
const parsed = { stages: [{ stageNumber: 1, entries: [{ actorLabel: 'wolf', key: 'p1_wolf', animObj: { bones: {} } }] }] };
|
||||
assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 9 }] }), /missing playback stage p9/);
|
||||
assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1 }, { stage: 1 }] }), /unique positive/);
|
||||
assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 2 }, { stage: 2, use_stage: 1 }] }), /requires animation clips.*p2/);
|
||||
});
|
||||
|
||||
test('canceling actor review leaves the open project and saved folders unchanged', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
|
||||
animations: { p1_wolf: { animation_length: 1, bones: {} } },
|
||||
});
|
||||
h.context.selectedFile = { path: file };
|
||||
h.context.answer = () => false;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.equal(h.context.Project.uuid, 'original');
|
||||
assert.equal(h.context.importedPlan, undefined);
|
||||
assert.equal(h.storage.size, 0);
|
||||
});
|
||||
|
||||
test('missing models can be supplied manually, and their locally supplied textures are not downloaded', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), {
|
||||
animations: { p1_wolf: { animation_length: 1, bones: { body: { position: [1, 2, 3] } } } },
|
||||
});
|
||||
const model = f.model(source, 'test:unrelated/wolf.m', { afw_texture: 'test:textures/unavailable.png' });
|
||||
h.context.selectedFile = { path: file };
|
||||
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models')
|
||||
? { ...result, gender_0: 'm', model_0: { path: model } } : result;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(h.context.importedPlan.rows[0].resolved.kind, 'Manually selected');
|
||||
assert.equal(h.context.importedPlan.rows[0].textures.size, 0);
|
||||
assert.ok(h.messages.some((message) => /Imported with notes:.*no base texture/.test(message)));
|
||||
});
|
||||
|
||||
test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => {
|
||||
const h = harness();
|
||||
h.storage.set('animationdirector_sound_source', path.resolve(__dirname, '../../NON/src/main/resources'));
|
||||
const plan = { parsed: { animationId: 'example' }, blockEntry: null,
|
||||
propEntries: { '["example","wolf","right",0]': { animation: 'example', item: 'minecraft:carrot', mode: 'item' } },
|
||||
definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] },
|
||||
imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': {
|
||||
bones: { actor1_body: { rotation: [1, 2, 3] } }, sound_effects: { '0.5': { effect: 'reactiveimpact' } },
|
||||
} } } },
|
||||
rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } },
|
||||
geo: { bones: [{ name: 'body' }] }, defaultTexture: 'test:textures/wolf.png',
|
||||
textures: new Map([['test:textures/wolf.png', { file: 'wolf.png' }]]) }] };
|
||||
h.commitAutomaticImport(plan);
|
||||
assert.equal(h.context.Project.uuid, 'new-project');
|
||||
assert.equal(h.context.Group.all[0].name, 'actor1_body');
|
||||
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'finish']);
|
||||
assert.equal(h.undo[1].aspects.automatic_animation_import, true);
|
||||
assert.equal(h.undo[1].aspects.groups.length, 1);
|
||||
const override = JSON.parse(h.context.Project.multiactor_bone_texture_overrides);
|
||||
assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png');
|
||||
assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example);
|
||||
assert.equal(JSON.parse(h.context.Project.animationdirector_prop_previews)['["example","wolf","right",0]'].item,'minecraft:carrot');
|
||||
const cue = h.context.Animation.all[0].animators.effects.sound[0].data_points[0];
|
||||
assert.equal(cue.effect, 'reactiveimpact');
|
||||
assert.equal(cue.file, 'blob:import-preview');
|
||||
});
|
||||
|
||||
test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => {
|
||||
const h = harness();
|
||||
h.context.Animator.loadFile = () => { throw new Error('load failed'); };
|
||||
assert.throws(() => h.commitAutomaticImport({ parsed: { animationId: 'example' }, rows: [],
|
||||
definition: {}, imported: { fileObj: {}, stageNames: [] } }), /load failed/);
|
||||
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']);
|
||||
});
|
||||
|
||||
test('manual replacement rejects duplicate and cyclic bones before touching the old actor', () => {
|
||||
const h = harness(), original = new h.context.Group({ name: 'actor1_body' }); original.init();
|
||||
for (const bones of [[{ name: 'body' }, { name: 'body' }], [{ name: 'body', parent: 'head' }, { name: 'head', parent: 'body' }]]) {
|
||||
assert.throws(() => h.importGeometryIntoCurrentProject({ bones }, 1, true), /unique|Cyclic/);
|
||||
assert.equal(h.context.Group.all[0], original);
|
||||
assert.equal(h.undo.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('manual geometry import cancels its undo transaction when model creation fails', () => {
|
||||
const h = harness();
|
||||
h.context.Group.prototype.init = () => { throw new Error('model creation failed'); };
|
||||
assert.throws(() => h.importGeometryIntoCurrentProject({ bones: [{ name: 'body' }] }, 1), /model creation failed/);
|
||||
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']);
|
||||
});
|
||||
|
||||
test('import asset lookup opens sources lazily and reuses positive and negative results', (t) => {
|
||||
const f = fixtures(t), h = harness(), first = f.makePack('first'), second = f.makePack('second');
|
||||
const relative = 'assets/test/textures/demo.png', png = Buffer.alloc(24);
|
||||
Buffer.from('89504e470d0a1a0a', 'hex').copy(png); png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20);
|
||||
const file = path.join(first.root, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, png);
|
||||
let secondOpened = 0, textureReads = 0;
|
||||
const counted = { path, fs: { ...fs, statSync(file, ...args) {
|
||||
if (file === second.root) secondOpened++;
|
||||
return fs.statSync(file, ...args);
|
||||
}, readFileSync(file, ...args) { if (file.endsWith('demo.png')) textureReads++; return fs.readFileSync(file, ...args); } } };
|
||||
const assets = h.createImportAssetLookup(counted, [first.root, second.root]);
|
||||
assert.equal(assets.read(relative).dimensions.width, 32);
|
||||
assert.equal(secondOpened, 0);
|
||||
assets.read(relative); assert.equal(textureReads, 1);
|
||||
assert.equal(assets.read('assets/test/textures/missing.png'), null);
|
||||
const opened = secondOpened;
|
||||
assert.equal(assets.read('assets/test/textures/missing.png'), null);
|
||||
assert.equal(secondOpened, opened);
|
||||
});
|
||||
@@ -0,0 +1,615 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function harness() {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
|
||||
' Plugin.register(PLUGIN_ID, {',
|
||||
' globalThis.blockTest = { readBlockRequirementForm, buildBlockRequirementGuideBoxes, stripBlockRequirementGuideBones, showAnimationDefinitionReviewDialog, rebuildBlockRequirementGuides, getAuthoredBlockRequirement, compileGeckoModelJson, createBlockRequirementPanel, hideInternalBlockGuideFaces, refreshBlockGuideFaces, prepareAnimationDefinitionImport, applyAnimationDefinitionImport, importAnimationDefinition, getSavedAnimdef, getPanel: () => blockRequirementPanel };\n Plugin.register(PLUGIN_ID, {'
|
||||
);
|
||||
const project = { uuid: 'project-one', multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
|
||||
const dialogs = [];
|
||||
const exports = [];
|
||||
const errors = [];
|
||||
const edits = [];
|
||||
const events = new Map();
|
||||
const previewEvents = new Map();
|
||||
const timers = new Map();
|
||||
const previewUpdates = [];
|
||||
const menus = [];
|
||||
let sequence = 0;
|
||||
class Group {
|
||||
static all = [];
|
||||
constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); }
|
||||
init() { Group.all.push(this); return this; }
|
||||
addTo(parent) { this.parent = parent; if (parent instanceof Group) parent.children.push(this); }
|
||||
remove() {
|
||||
for (const child of this.children.slice()) child.remove();
|
||||
Group.all = Group.all.filter((group) => group !== this);
|
||||
}
|
||||
}
|
||||
class Cube {
|
||||
static all = [];
|
||||
static preview_controller = {
|
||||
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: {},
|
||||
faces: Object.fromEntries(['north', 'south', 'east', 'west', 'up', 'down'].map((name) => [name, { texture: false }])) });
|
||||
}
|
||||
init() { Cube.all.push(this); }
|
||||
addTo(parent) { this.parent = parent; parent.children.push(this); }
|
||||
getAllAncestors() { return [this.parent]; }
|
||||
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 }); } },
|
||||
Timeline: { loop() {} },
|
||||
Panel: class {
|
||||
constructor(id, options) {
|
||||
this.options = options;
|
||||
this.vue = Object.assign(options.component.data(), options.component.methods);
|
||||
this.vue.$set = (object, key, value) => { object[key] = value; };
|
||||
}
|
||||
},
|
||||
setTimeout(callback) { const id = ++sequence; timers.set(id, callback); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
MenuBar: { menus: { tools: { addAction(menu) { menus.push(menu); } } }, update() {} },
|
||||
THREE: { MeshBasicMaterial: class { constructor(options) { Object.assign(this, options); } }, DoubleSide: 2 },
|
||||
Canvas: {
|
||||
updateAll() {
|
||||
for (const cube of Cube.all) {
|
||||
cube.mesh.material = 'selected-shared-texture';
|
||||
previewEvents.get('update_faces')?.({ element: cube });
|
||||
}
|
||||
},
|
||||
updateAllFaces() { this.updateAll(); },
|
||||
updateView(options) {
|
||||
previewUpdates.push(options);
|
||||
for (const cube of options.elements || []) {
|
||||
cube.mesh.material = 'selected-shared-texture';
|
||||
if (options.element_aspects?.faces) previewEvents.get('update_faces')?.({ element: cube });
|
||||
}
|
||||
},
|
||||
},
|
||||
Undo: {
|
||||
initEdit(aspects) { edits.push({ before: project.animationdirector_block_requirements, aspects }); },
|
||||
finishEdit(action, aspects) { Object.assign(edits.at(-1), { after: project.animationdirector_block_requirements, action, post: aspects }); },
|
||||
},
|
||||
Dialog: class { constructor(options) { dialogs.push(options); } show() {} },
|
||||
Blockbench: { isWeb: false, export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
|
||||
on(names, listener) { for (const name of names.split(' ')) events.set(name, listener); },
|
||||
addCSS() { return { delete() {} }; },
|
||||
showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } },
|
||||
Buffer, requireNativeModule: require, console,
|
||||
};
|
||||
vm.runInNewContext(source, context);
|
||||
return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, previewUpdates, menus, Group, Cube };
|
||||
}
|
||||
|
||||
function wallForm(overrides = {}) {
|
||||
return { type: 'wall', blocks: '#minecraft:fences', height_min: 3, height_max: '', width_min: 2, width_max: '',
|
||||
facing: '', clearance_width: 1, clearance_height: 2, clearance_depth: 1, wall_thickness: 1, ...overrides };
|
||||
}
|
||||
|
||||
function importAnalysis() {
|
||||
return { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
|
||||
stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 },
|
||||
{ stageNumber: 2, name: 'test.p2', loop: false, cycleSeconds: 2 }], warnings: [] };
|
||||
}
|
||||
|
||||
test('imported beds and fences receive their editor-only preview dimensions', () => {
|
||||
const cases = [
|
||||
['center_support', '#minecraft:beds', 0.5625],
|
||||
['center_support', 'minecraft:red_bed', 0.5625],
|
||||
['center_support', ['minecraft:white_bed', 'minecraft:blue_bed'], 0.5625],
|
||||
['wall', '#minecraft:fences', 0.25],
|
||||
['wall', 'minecraft:oak_fence', 0.25],
|
||||
['wall', 'minecraft:nether_brick_fence', 0.25],
|
||||
['wall', 'minecraft:oak_fence_gate', 1],
|
||||
['wall', ['minecraft:oak_fence', 'minecraft:stone'], 1],
|
||||
];
|
||||
for (const [type, blocks, expected] of cases) {
|
||||
const h = harness();
|
||||
const requirement = type === 'wall' ? { type, blocks, height: { min: 1 }, width: { min: 1 } }
|
||||
: { type, blocks, support: 'surface', placement: 'surface_footprint' };
|
||||
const source = { actors: [{ label: 'player' }, { label: 'wolf' }], stages: [{ stage: 1 }, { stage: 2 }],
|
||||
block_requirements: requirement };
|
||||
h.project.animationdirector_block_requirements = JSON.stringify({ test: {
|
||||
requirement: { type: 'wall', blocks: 'minecraft:stone' }, preview: { surfaceHeight: 1.5, wallThickness: 0.75 } } });
|
||||
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source);
|
||||
const entry = h.getAuthoredBlockRequirement('test');
|
||||
assert.equal(type === 'wall' ? entry.preview.wallThickness : entry.preview.surfaceHeight, expected);
|
||||
assert.equal(h.getSavedAnimdef('test').block_requirements.preview, undefined);
|
||||
if (type === 'center_support') {
|
||||
const surface = h.buildBlockRequirementGuideBoxes(entry).find((box) => box.kind === 'required');
|
||||
assert.ok(surface);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('desktop-only tools are visible in desktop Blockbench and hidden in browser Blockbench', () => {
|
||||
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_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`);
|
||||
assert.equal(action.condition(), true, `${id} is visible on desktop`);
|
||||
h.context.Blockbench.isWeb = true;
|
||||
assert.equal(action.condition(), false, `${id} is hidden in browsers`);
|
||||
h.context.Blockbench.isWeb = false;
|
||||
}
|
||||
});
|
||||
|
||||
test('animation menu orders import and creation actions and has no deprecated split-file tools', () => {
|
||||
const h = harness();
|
||||
h.context.registration.onload();
|
||||
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
|
||||
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', 'NoN Particle Library', 'Particle Previews',
|
||||
'_', 'Animation Definition', 'Export Animations',
|
||||
]);
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);
|
||||
});
|
||||
|
||||
test('import prefills generator fields, builds guides and retains fields without UI controls on export', () => {
|
||||
const h = harness();
|
||||
const source = {
|
||||
display_name: 'Imported animation', animators: ['Animator'], content_tags: ['test'],
|
||||
system_tags: ['manualpeak'], weight: 2.5, required_union_tags: ['example.tag'],
|
||||
join_after: ['example:previous'], match_content_on_join: true, attack_eligible: false,
|
||||
liquid_gain_multiplier: 0.5, manual_peak: { priority: 5 }, speed: 1.5,
|
||||
water: 'surface', position_anchor_actor: 'wolf',
|
||||
camera: { torso_tracking: 'continuous', block_requirement_collision: 'ignore' },
|
||||
addon_data: { example: { flag: true } }, escapable: false,
|
||||
actors: [
|
||||
{ label: 'player', entity_types: ['minecraft:player'], activity: 'passive', receiver: true, age: 'adult' },
|
||||
{ label: 'wolf', entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'], activity: 'active',
|
||||
injector: 'V', injects_into: 'player', held_item: { items: ['#minecraft:swords'], hand: 'either', prop_slot: 'right' } },
|
||||
],
|
||||
stages: [
|
||||
{ stage: 1, loop: true, cycle_seconds: 0.75, allow_join: false, escapable: true,
|
||||
speed: 1.25, stage_duration_multiplier: 0.5, cycle_midpoint_offset_seconds: 0.1,
|
||||
props: { right: { item: 'minecraft:carrot' } } },
|
||||
{ stage: 2, loop: false, cycle_seconds: 1.75, allow_join: true, escapable: false, non_peak: true },
|
||||
],
|
||||
block_requirements: { type: 'wall', blocks: '#minecraft:fences', height: { min: 3 },
|
||||
width: { min: 2, max: 2, both_axes: true }, facing: 'front', clearance: { width: 1, height: 2, depth: 1 } },
|
||||
};
|
||||
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source);
|
||||
const dialog = h.dialogs.at(-1);
|
||||
const values = Object.fromEntries(Object.entries(dialog.form)
|
||||
.filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value]));
|
||||
assert.equal(values.display_name, source.display_name);
|
||||
assert.equal(values.animators, 'Animator');
|
||||
assert.equal(values.weight, 2.5);
|
||||
assert.equal(values.system_tags, 'manualpeak');
|
||||
assert.equal(values.manual_peak_priority, 5);
|
||||
assert.equal(values.stub_actor_1_receiver, true);
|
||||
assert.equal(values.stub_actor_2_injector, 'V');
|
||||
assert.deepEqual(Array.from(values.stub_actor_2_injects_into), ['player']);
|
||||
assert.equal(values.stub_actor_2_held_items, '#minecraft:swords');
|
||||
assert.equal(values.stub_actor_2_held_hand, 'either');
|
||||
assert.equal(values.stub_stage_1_duration_multiplier, 0.5);
|
||||
assert.equal(values.stub_stage_1_cycle_seconds, 0.75);
|
||||
assert.equal(values.stub_stage_2_peaked, true);
|
||||
assert.ok(h.Cube.all.length > 0);
|
||||
assert.equal(h.edits.length, 1);
|
||||
assert.equal(h.edits[0].action, 'Import animation definition');
|
||||
assert.equal(h.edits[0].aspects.animdef_edit, true);
|
||||
assert.equal(dialog.onConfirm(values), true);
|
||||
const exported = h.exports.at(-1);
|
||||
assert.deepEqual(exported.stages[0].props, source.stages[0].props);
|
||||
assert.deepEqual(exported.addon_data, source.addon_data);
|
||||
assert.equal(exported.actors[0].age, 'adult');
|
||||
assert.equal(exported.actors[1].injects_into, 'player');
|
||||
assert.equal(exported.block_requirements.width.both_axes, true);
|
||||
assert.equal(h.errors.length, 0);
|
||||
});
|
||||
|
||||
test('importing an animdef replaces saved fields instead of retaining old settings', () => {
|
||||
const h = harness();
|
||||
h.project.multiactor_animdef_animators = 'Old author';
|
||||
h.project.animationdirector_animdef_definitions = JSON.stringify({ test: {
|
||||
display_name: 'Old', weight: 8, content_tags: ['old'], addon_data: { old: true },
|
||||
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
|
||||
stages: [{ stage: 1, non_peak: true }, { stage: 2 }],
|
||||
}, other: { display_name: 'Unchanged' } });
|
||||
const replacement = { display_name: 'Replacement',
|
||||
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
|
||||
stages: [{ stage: 1 }, { stage: 2, non_peak: true }],
|
||||
};
|
||||
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), replacement);
|
||||
const saved = h.getSavedAnimdef('test');
|
||||
assert.equal(saved.display_name, 'Replacement');
|
||||
assert.equal(saved.weight, undefined);
|
||||
assert.equal(saved.content_tags, undefined);
|
||||
assert.equal(saved.addon_data, undefined);
|
||||
assert.equal(h.getSavedAnimdef('other').display_name, 'Unchanged');
|
||||
const dialog = h.dialogs.at(-1);
|
||||
assert.equal(dialog.form.stub_stage_1_peaked.value, false);
|
||||
assert.equal(dialog.form.stub_stage_2_peaked.value, true);
|
||||
assert.equal(dialog.form.animators.value, '');
|
||||
});
|
||||
|
||||
test('import maps explicit labels before positional fallbacks and updates actor references', () => {
|
||||
const h = harness();
|
||||
const source = { actors: [{ label: 'wolf', injector: 'V', injects_into: 'bottom' },
|
||||
{ label: 'bottom', receiver: true }], stages: [{ stage: 1, props: { bottom: { prop_right: 'minecraft:carrot' } } },
|
||||
{ stage: 2 }], position_anchor_actor: 'bottom' };
|
||||
const definition = h.prepareAnimationDefinitionImport(source, importAnalysis());
|
||||
assert.equal(definition.actors[0].label, 'player');
|
||||
assert.equal(definition.actors[0].receiver, true);
|
||||
assert.equal(definition.actors[1].label, 'wolf');
|
||||
assert.deepEqual(Array.from(definition.actors[1].injects_into), ['player']);
|
||||
assert.equal(definition.position_anchor_actor, 'player');
|
||||
assert.equal(definition.stages[0].props.player.prop_right, 'minecraft:carrot');
|
||||
assert.equal(source.actors[1].label, 'bottom', 'Input JSON is not mutated');
|
||||
const positional = h.prepareAnimationDefinitionImport({ actors: [{ receiver: true }, { injector: 'V' }],
|
||||
stages: [{ stage: 1 }, { stage: 2 }] }, importAnalysis());
|
||||
assert.equal(positional.actors[0].label, 'player');
|
||||
assert.equal(positional.actors[1].label, 'wolf');
|
||||
});
|
||||
|
||||
test('manual definition import embeds item prop previews in the same undoable edit', (t) => {
|
||||
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-manual-props-'));
|
||||
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
|
||||
const write=(relative,data)=>{ const file=path.join(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true});
|
||||
fs.writeFileSync(file,typeof data==='string'?data:JSON.stringify(data)); };
|
||||
write('assets/minecraft/items/carrot.json',{model:{type:'minecraft:model',model:'minecraft:item/carrot'}});
|
||||
write('assets/minecraft/models/item/carrot.json',{parent:'minecraft:builtin/generated',textures:{layer0:'minecraft:item/carrot'}});
|
||||
write('assets/minecraft/textures/item/carrot.png','image');
|
||||
h.applyAnimationDefinitionImport({id:'test'},importAnalysis(),{
|
||||
actors:[{label:'player',prop_right:'minecraft:carrot'},{label:'wolf'}],
|
||||
stages:[{stage:1},{stage:2,props:{player:{prop_right:null}}}],
|
||||
},[root]);
|
||||
const previews=JSON.parse(h.project.animationdirector_prop_previews);
|
||||
assert.equal(previews['["test","player","right",0]'].item,'minecraft:carrot');
|
||||
assert.equal(previews['["test","player","right",2]'].mode,'hidden');
|
||||
assert.equal(h.edits.at(-1).aspects.prop_preview_edit,true);
|
||||
assert.equal(h.edits.at(-1).post.prop_preview_edit,true);
|
||||
});
|
||||
|
||||
test('invalid imports leave definitions and guides unchanged', () => {
|
||||
const h = harness();
|
||||
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
|
||||
const before = h.project.animationdirector_block_requirements;
|
||||
const source = { actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }] };
|
||||
for (const invalid of [null, { animations: {} }, { ...source, actors: [{}] },
|
||||
{ ...source, stages: [{ stage: 1 }] }, { ...source, stages: [{ stage: 1 }, { stage: 1 }] },
|
||||
{ ...source, actors: [{ label: 'same' }, { label: 'same' }] },
|
||||
{ ...source, actors: [{ injects_into: 'unknown' }, {}] },
|
||||
{ ...source, content_tags: 'wrong' },
|
||||
{ ...source, block_requirements: { type: 'wall', width: { min: 2, max: 1 } } }]) {
|
||||
assert.throws(() => h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), invalid));
|
||||
}
|
||||
assert.equal(h.project.animationdirector_block_requirements, before);
|
||||
assert.equal(h.project.animationdirector_animdef_definitions, undefined);
|
||||
assert.equal(h.edits.length, 1);
|
||||
});
|
||||
|
||||
test('import without a requirement removes old guides and undo restores definition metadata too', () => {
|
||||
const h = harness();
|
||||
h.context.registration.onload();
|
||||
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
|
||||
const before = {};
|
||||
h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true, animdef_edit: true } });
|
||||
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), {
|
||||
actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }], animation_tags: ['defeat'] });
|
||||
assert.equal(h.Cube.all.length, 0);
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
|
||||
assert.deepEqual(Array.from(h.getSavedAnimdef('test').system_tags), ['defeat']);
|
||||
assert.equal(h.dialogs.at(-1).form.stub_stage_2_peaked.value, false, 'Missing peak flags are not invented');
|
||||
const after = {};
|
||||
h.events.get('create_undo_save')({ save: after, aspects: { block_requirement_edit: true, animdef_edit: true } });
|
||||
h.events.get('load_undo_save')({ save: before });
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall');
|
||||
assert.equal(Object.keys(h.getSavedAnimdef('test')).length, 0);
|
||||
h.events.get('load_undo_save')({ save: after });
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
|
||||
assert.equal(h.getSavedAnimdef('test').actors.length, 2);
|
||||
});
|
||||
|
||||
test('building, editing, refreshing and removing guides never resets actor or folder texture overrides', () => {
|
||||
const h = harness();
|
||||
const actor = new h.Group({ name: 'actor1_body' });
|
||||
actor.init(); actor.addTo('root');
|
||||
const cubes = ['actor-texture', 'folder-override-texture'].map((material, index) => {
|
||||
const cube = new h.Cube({ name: `actor1_cube${index}`, from: [0, 0, 0], to: [16, 16, 16] });
|
||||
cube.init(); cube.addTo(actor);
|
||||
cube.mesh.material = material;
|
||||
for (const face of Object.values(cube.faces)) face.texture = material;
|
||||
return cube;
|
||||
});
|
||||
const before = cubes.map((cube) => ({ material: cube.mesh.material, faces: JSON.stringify(cube.faces) }));
|
||||
h.context.registration.onload();
|
||||
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
|
||||
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm({ width_min: 3, clearance_depth: 2 })));
|
||||
h.refreshBlockGuideFaces();
|
||||
h.rebuildBlockRequirementGuides('test', null);
|
||||
assert.deepEqual(cubes.map((cube) => ({ material: cube.mesh.material, faces: JSON.stringify(cube.faces) })), before);
|
||||
for (const update of h.previewUpdates) {
|
||||
assert.ok(update.elements.every((cube) => !cubes.includes(cube)));
|
||||
assert.ok((update.groups || []).every((group) => group !== actor));
|
||||
}
|
||||
assert.equal(h.previewUpdates.length, 4);
|
||||
});
|
||||
|
||||
test('guide cubes omit internal faces, preserve colored boundaries and restore newly exposed faces', () => {
|
||||
const h = harness();
|
||||
const make = (kind, from, to) => new h.Cube({ name: `__ad_block_requirement_${kind}_test`, from, to });
|
||||
const left = make('required', [0, 0, 0], [16, 16, 16]);
|
||||
const right = make('required', [16, 0, 0], [32, 16, 16]);
|
||||
const clearance = make('clearance', [32, 0, 0], [48, 16, 16]);
|
||||
h.hideInternalBlockGuideFaces([left, right, clearance]);
|
||||
assert.equal(left.faces.east.texture, null);
|
||||
assert.equal(right.faces.west.texture, null);
|
||||
assert.equal(left.faces.west.texture, false);
|
||||
assert.equal(right.faces.east.texture, false, 'Blue/red boundaries remain visible');
|
||||
assert.equal(clearance.faces.west.texture, false);
|
||||
h.hideInternalBlockGuideFaces([left]);
|
||||
assert.equal(left.faces.east.texture, false, 'Removing a neighbor exposes its former shared face');
|
||||
});
|
||||
|
||||
test('several neighbors can cover one larger guide face without hiding partially exposed surfaces', () => {
|
||||
const h = harness();
|
||||
const make = (from, to) => new h.Cube({ name: '__ad_block_requirement_clearance_test', from, to });
|
||||
const tall = make([0, 0, 0], [16, 32, 16]);
|
||||
const lower = make([16, 0, 0], [32, 16, 16]);
|
||||
const upper = make([16, 16, 0], [32, 32, 16]);
|
||||
h.hideInternalBlockGuideFaces([tall, lower, upper]);
|
||||
assert.equal(tall.faces.east.texture, null);
|
||||
assert.equal(lower.faces.west.texture, null);
|
||||
assert.equal(upper.faces.west.texture, null);
|
||||
assert.equal(lower.faces.up.texture, null);
|
||||
assert.equal(upper.faces.down.texture, null);
|
||||
h.hideInternalBlockGuideFaces([tall, lower]);
|
||||
assert.equal(tall.faces.east.texture, false, 'The upper exposed half must not disappear');
|
||||
assert.equal(lower.faces.up.texture, false);
|
||||
});
|
||||
|
||||
test('generated and reloaded guides remove their shared faces without affecting another animation', () => {
|
||||
const h = harness();
|
||||
h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm()));
|
||||
const firstFaces = h.Cube.all.map((cube) => JSON.stringify(cube.faces));
|
||||
assert.ok(h.Cube.all.some((cube) => Object.values(cube.faces).some((face) => face.texture === null)));
|
||||
h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm()));
|
||||
assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces);
|
||||
for (const cube of h.Cube.all) for (const face of Object.values(cube.faces)) face.texture = false;
|
||||
h.refreshBlockGuideFaces();
|
||||
assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces);
|
||||
});
|
||||
|
||||
test('wall data matches NoN minimum/maximum/facing syntax and ignores inactive fields', () => {
|
||||
const h = harness();
|
||||
const entry = h.readBlockRequirementForm(wallForm({ width_max: '2', height_max: '3', both_axes: true,
|
||||
include_obstructions: true, facing: 'front', surface_radius: 9, footprint_width: 9 }));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(entry.requirement)), {
|
||||
type: 'wall', blocks: '#minecraft:fences', height: { min: 3, max: 3 },
|
||||
width: { min: 2, max: 2, both_axes: true, include_obstructions: true }, facing: 'front',
|
||||
clearance: { width: 1, height: 2, depth: 1 },
|
||||
});
|
||||
assert.equal(h.readBlockRequirementForm(wallForm({ both_axes: true })).requirement.width.both_axes, undefined);
|
||||
assert.throws(() => h.readBlockRequirementForm(wallForm({ width_max: 1 })), /Maximum width/);
|
||||
assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: '', facing: 'front' })), /facing requirement/);
|
||||
assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: 'fence' })), /Invalid block/);
|
||||
});
|
||||
|
||||
test('wall guides center an even span and reproduce side padding, anchor distance and required floors', () => {
|
||||
const h = harness();
|
||||
const entry = h.readBlockRequirementForm(wallForm({ clearance_width: 2 }));
|
||||
const boxes = h.buildBlockRequirementGuideBoxes(entry);
|
||||
const walls = boxes.filter((box) => box.label === 'wall');
|
||||
assert.equal(walls.length, 6);
|
||||
assert.equal(Math.min(...walls.map((box) => box.from[0])), -16);
|
||||
assert.equal(Math.max(...walls.map((box) => box.to[0])), 16);
|
||||
assert.equal(walls[0].from[2], -24);
|
||||
assert.equal(walls[0].to[2], -8);
|
||||
const clearance = boxes.filter((box) => box.kind === 'clearance');
|
||||
assert.equal(clearance.length, 8);
|
||||
assert.equal(Math.min(...clearance.map((box) => box.from[0])), -32);
|
||||
assert.equal(Math.max(...clearance.map((box) => box.to[0])), 32);
|
||||
assert.equal(clearance[0].from[2], -8);
|
||||
assert.equal(boxes.filter((box) => box.label === 'floor').length, 6);
|
||||
const thin = h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ wall_thickness: 0.25 })));
|
||||
assert.equal(thin.find((box) => box.label === 'wall').from[2], -12);
|
||||
assert.equal(thin.find((box) => box.kind === 'clearance').from[2], -2);
|
||||
});
|
||||
|
||||
test('center footprint supports fractional heights and clearance margins without directional fields', () => {
|
||||
const h = harness();
|
||||
const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface',
|
||||
blocks: '#minecraft:beds', placement: 'surface_footprint', footprint_width: 2, footprint_depth: 2,
|
||||
footprint_height: 2, footprint_margin: 1, surface_height: 0.5625 });
|
||||
assert.equal(entry.requirement.clearance, undefined);
|
||||
assert.equal(entry.requirement.height, undefined);
|
||||
const boxes = h.buildBlockRequirementGuideBoxes(entry);
|
||||
assert.equal(boxes.filter((box) => box.kind === 'required').length, 4);
|
||||
assert.equal(boxes.filter((box) => box.kind === 'clearance').length, 32);
|
||||
assert.equal(boxes.find((box) => box.label === 'support').to[1], 9);
|
||||
assert.equal(Math.min(...boxes.filter((box) => box.kind === 'clearance').map((box) => box.from[0])), -24);
|
||||
assert.throws(() => h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface',
|
||||
placement: 'directional_clearance', surface_radius: 0, blocks: '' }), /requires a block/);
|
||||
});
|
||||
|
||||
test('directional center guides union overlapping above-surface checks and enforce preview size limits', () => {
|
||||
const h = harness();
|
||||
const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'slab',
|
||||
placement: 'directional_clearance', surface_radius: 1 });
|
||||
const boxes = h.buildBlockRequirementGuideBoxes(entry);
|
||||
const front = boxes.filter((box) => box.kind === 'clearance' && box.from[0] === -8 && box.from[2] === 8);
|
||||
assert.equal(front.length, 1);
|
||||
assert.equal(front[0].from[1], 0);
|
||||
assert.equal(front[0].to[1], 40);
|
||||
assert.throws(() => h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ width_min: 32,
|
||||
clearance_width: 32, clearance_depth: 32, clearance_height: 32 }))), /4096/);
|
||||
});
|
||||
|
||||
test('editing replaces only target guides, persists metadata and removal overrides previously exported requirements', () => {
|
||||
const h = harness();
|
||||
h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm()));
|
||||
h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm()));
|
||||
const second = h.Group.all.find((group) => group.name.endsWith('second'));
|
||||
const first = h.Group.all.find((group) => group.name.endsWith('first'));
|
||||
const firstCube = h.Cube.all[0];
|
||||
h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm({ width_min: 1 })));
|
||||
assert.equal(h.Group.all.length, 2);
|
||||
assert.ok(h.Group.all.includes(first));
|
||||
assert.ok(h.Cube.all.includes(firstCube));
|
||||
assert.ok(h.Group.all.includes(second));
|
||||
assert.ok(h.Cube.all.every((cube) => cube.export === false));
|
||||
assert.ok(h.Group.all.every((group) => group.export === false));
|
||||
assert.equal(h.getAuthoredBlockRequirement('first').requirement.width.min, 1);
|
||||
assert.equal(h.edits.at(-1).aspects.block_requirement_edit, true);
|
||||
assert.equal(h.edits.at(-1).post.block_requirement_edit, true);
|
||||
h.rebuildBlockRequirementGuides('first', null);
|
||||
assert.equal(h.Group.all.length, 1);
|
||||
assert.equal(h.getAuthoredBlockRequirement('first').requirement, null);
|
||||
});
|
||||
|
||||
test('live panel coalesces input bursts and leaves invalid edits out of the project and undo history', () => {
|
||||
const h = harness();
|
||||
h.createBlockRequirementPanel();
|
||||
const view = h.getPanel().vue;
|
||||
view.load('test');
|
||||
assert.equal(h.edits.length, 0, 'Opening the editor does not add a requirement');
|
||||
view.flush(true);
|
||||
const cube = h.Cube.all[0];
|
||||
const field = view.form.clearance_depth;
|
||||
view.input('clearance_depth', field, { target: { value: '2' } });
|
||||
view.input('clearance_depth', field, { target: { value: '3' } });
|
||||
assert.equal(h.timers.size, 1);
|
||||
assert.equal(h.edits.length, 1);
|
||||
for (const callback of h.timers.values()) callback();
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.edits.length, 2, 'The burst creates one undo entry');
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3);
|
||||
assert.ok(h.Cube.all.includes(cube));
|
||||
view.input('clearance_depth', field, { target: { value: '' } });
|
||||
view.flush();
|
||||
assert.match(view.error, /last valid preview/);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.edits.length, 2);
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3);
|
||||
view.input('clearance_depth', field, { target: { value: '3' } });
|
||||
view.flush();
|
||||
assert.equal(view.error, '');
|
||||
assert.equal(h.edits.length, 2, 'Unchanged values do not add undo entries');
|
||||
});
|
||||
|
||||
test('panel switches animations safely, resets on project changes and reloads restored undo metadata', () => {
|
||||
const h = harness();
|
||||
h.context.registration.onload();
|
||||
h.createBlockRequirementPanel();
|
||||
const view = h.getPanel().vue;
|
||||
view.load('first');
|
||||
view.input('clearance_depth', view.form.clearance_depth, { target: { value: '4' } });
|
||||
view.selectId({ target: { value: 'second' } });
|
||||
assert.equal(h.getAuthoredBlockRequirement('first').requirement.clearance.depth, 4);
|
||||
assert.equal(view.animationId, 'second');
|
||||
assert.equal(h.getAuthoredBlockRequirement('second'), null);
|
||||
view.flush(true);
|
||||
const before = h.project.animationdirector_block_requirements;
|
||||
view.input('clearance_depth', view.form.clearance_depth, { target: { value: '5' } });
|
||||
view.flush();
|
||||
h.events.get('load_undo_save')({ save: { animationdirector_block_requirements: before } });
|
||||
h.events.get('undo')();
|
||||
assert.equal(view.values.clearance_depth, 1);
|
||||
view.input('clearance_depth', view.form.clearance_depth, { target: { value: '7' } });
|
||||
h.context.Project = { uuid: 'another-project' };
|
||||
h.events.get('select_project')();
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(view.animationId, '');
|
||||
assert.equal(h.context.Project.animationdirector_block_requirements, undefined);
|
||||
assert.equal(JSON.parse(h.project.animationdirector_block_requirements).second.requirement.clearance.depth, 1);
|
||||
});
|
||||
|
||||
test('panel conditionally exposes wall and center-support sections without exporting preview settings', () => {
|
||||
const h = harness();
|
||||
h.createBlockRequirementPanel();
|
||||
const panel = h.getPanel();
|
||||
const view = panel.vue;
|
||||
view.load('test');
|
||||
const titles = () => Array.from(panel.options.component.computed.sections.call(view), (section) => section.title);
|
||||
assert.ok(titles().includes('Wall structure'));
|
||||
assert.ok(!titles().includes('Center support'));
|
||||
view.values.type = 'center_support';
|
||||
view.values.placement = 'surface_footprint';
|
||||
assert.ok(titles().includes('Center support'));
|
||||
assert.ok(!titles().includes('Wall structure'));
|
||||
assert.ok(!titles().includes('Directional clearance'));
|
||||
view.flush(true);
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'center_support');
|
||||
view.remove();
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
|
||||
assert.equal(h.Cube.all.length, 0);
|
||||
});
|
||||
|
||||
test('guide material survives face rebuilds and metadata participates in Blockbench undo saves', () => {
|
||||
const h = harness();
|
||||
h.context.registration.onload();
|
||||
const before = {};
|
||||
h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true } });
|
||||
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
|
||||
const post = {};
|
||||
h.events.get('create_undo_save')({ save: post, aspects: { block_requirement_edit: true } });
|
||||
for (const kind of ['required', 'clearance']) {
|
||||
const cube = h.Cube.all.find((candidate) => candidate.name.includes(`_${kind}_`));
|
||||
assert.equal(cube.mesh.material.opacity, 0.22);
|
||||
assert.equal(cube.mesh.material.transparent, true);
|
||||
cube.mesh.material = {};
|
||||
h.previewEvents.get('update_faces')({ element: cube });
|
||||
assert.equal(cube.mesh.material.opacity, 0.22);
|
||||
}
|
||||
h.events.get('load_undo_save')({ save: before });
|
||||
assert.equal(h.getAuthoredBlockRequirement('test'), null);
|
||||
h.events.get('load_undo_save')({ save: post });
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall');
|
||||
});
|
||||
|
||||
test('generator includes only the selected animation requirement and strips guide bones from exports', () => {
|
||||
const h = harness();
|
||||
const entry = h.readBlockRequirementForm(wallForm());
|
||||
h.rebuildBlockRequirementGuides('test', entry);
|
||||
const analysis = { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
|
||||
stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 }], warnings: [] };
|
||||
function generate(id) {
|
||||
h.showAnimationDefinitionReviewDialog({ id }, analysis);
|
||||
const dialog = h.dialogs.at(-1);
|
||||
const values = Object.fromEntries(Object.entries(dialog.form)
|
||||
.filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value]));
|
||||
assert.equal(dialog.onConfirm(values), true);
|
||||
return h.exports.at(-1);
|
||||
}
|
||||
assert.deepEqual(generate('test').block_requirements, JSON.parse(JSON.stringify(entry.requirement)));
|
||||
assert.equal(generate('other').block_requirements, undefined);
|
||||
h.rebuildBlockRequirementGuides('test', null);
|
||||
assert.equal(generate('test').block_requirements, undefined);
|
||||
assert.equal(h.errors.length, 0);
|
||||
const clip = { bones: { __ad_block_requirement_test: { rotation: [1, 2, 3] }, actor1_body: { position: [0, 1, 2] } }, sound_effects: { 0: 'sound' } };
|
||||
h.stripBlockRequirementGuideBones(clip);
|
||||
assert.deepEqual(Object.keys(clip.bones), ['actor1_body']);
|
||||
assert.ok(clip.sound_effects);
|
||||
h.context.Format = { codec: { compile: () => ({ 'minecraft:geometry': [{ bones: [
|
||||
{ name: '__ad_block_requirement_test' }, { name: 'renamed_guide', parent: '__ad_block_requirement_test' },
|
||||
{ name: 'actor1_body' },
|
||||
] }] }) } };
|
||||
h.context.Codecs = {};
|
||||
assert.deepEqual(Array.from(h.compileGeckoModelJson()['minecraft:geometry'][0].bones, (bone) => bone.name), ['actor1_body']);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
function harness() {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
|
||||
' Plugin.register(PLUGIN_ID, {', `globalThis.props = { indexPropZip, propResourcePath, openPropSource,
|
||||
loadPropAsset, resolvePropModel, propTextureId, propDisplayTransform, manualPropModel,
|
||||
writePropDefinition, effectivePropEntry, effectivePropItem, propEntryKey, extrudePropPixels, discoverPropSources,
|
||||
buildPropMesh, refreshPropPreviews, clearPropMeshes, propFaceVertices, propDefaultUv,
|
||||
applyPropPreview, createPropDialog, showPropDialog, readPropPreviews, importPropPreviews };
|
||||
Plugin.register(PLUGIN_ID, {`);
|
||||
const context = { Buffer, requireNativeModule: require, console, Plugin: { register() {} },
|
||||
Project: { uuid: 'test' }, Group: { all: [] }, Animation: { all: [] }, localStorage: { getItem() { return null; } } };
|
||||
vm.runInNewContext(source, context);
|
||||
return { ...context.props, context };
|
||||
}
|
||||
|
||||
function source(entries) {
|
||||
return { read(name) { const value = entries[name]; return value == null ? null : Buffer.from(typeof value === 'object' ? JSON.stringify(value) : value); } };
|
||||
}
|
||||
|
||||
function zipFixture(name, content, compressed = false) {
|
||||
const bytes = Buffer.from(content), data = compressed ? zlib.deflateRawSync(bytes) : bytes, filename = Buffer.from(name);
|
||||
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(compressed ? 8 : 0, 8);
|
||||
local.writeUInt32LE(data.length, 18); local.writeUInt32LE(bytes.length, 22); local.writeUInt16LE(filename.length, 26);
|
||||
const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(compressed ? 8 : 0, 10);
|
||||
central.writeUInt32LE(data.length, 20); central.writeUInt32LE(bytes.length, 24); central.writeUInt16LE(filename.length, 28);
|
||||
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(central.length + filename.length, 12); end.writeUInt32LE(local.length + filename.length + data.length, 16);
|
||||
return Buffer.concat([local,filename,data,central,filename,end]);
|
||||
}
|
||||
|
||||
test('ZIP asset lookup supports stored/deflated entries and rejects corrupt/oversized archives', () => {
|
||||
const h = harness(), name = 'assets/minecraft/models/item/example.json';
|
||||
for (const deflate of [false,true]) assert.equal(h.indexPropZip(zipFixture(name,'{}',deflate)).read(name).toString(), '{}');
|
||||
assert.throws(() => h.indexPropZip(Buffer.from('not a zip')), /Not a supported/);
|
||||
const oversized = zipFixture(name,'{}');
|
||||
oversized.writeUInt32LE(17000000, 30 + name.length + 2 + 24);
|
||||
assert.throws(() => h.indexPropZip(oversized).read(name), /oversized/);
|
||||
const broken = zipFixture(name,'{}'); broken.writeUInt32LE(0,0);
|
||||
assert.throws(() => h.indexPropZip(broken).read(name), /Invalid asset entry/);
|
||||
});
|
||||
|
||||
test('resource paths cannot escape assets and extracted sources resolve textures', (t) => {
|
||||
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-props-'));
|
||||
t.after(() => fs.rmSync(root,{ recursive:true,force:true }));
|
||||
for (const id of ['minecraft:../private','../private','minecraft:item//foo','minecraft:/foo']) assert.throws(() => h.propResourcePath(id,'models','.json'));
|
||||
fs.mkdirSync(path.join(root,'assets/minecraft/textures'),{recursive:true});
|
||||
fs.writeFileSync(path.join(root,'assets/minecraft/textures/test.png'),'image');
|
||||
assert.equal(h.openPropSource(path.join(root,'assets')).read('assets/minecraft/textures/test.png').toString(),'image');
|
||||
});
|
||||
|
||||
test('generated models inherit textures/display and resolve modern item definitions', () => {
|
||||
const h = harness();
|
||||
const s = source({
|
||||
'assets/minecraft/items/carrot.json': {model:{type:'minecraft:model',model:'minecraft:item/carrot'}},
|
||||
'assets/minecraft/models/item/carrot.json': {parent:'minecraft:item/generated',textures:{layer0:'#actual',actual:'minecraft:item/carrot'}},
|
||||
'assets/minecraft/models/item/generated.json': {parent:'builtin/generated',display:h.manualPropModel('generated').display},
|
||||
'assets/minecraft/textures/item/carrot.png':'PNG',
|
||||
});
|
||||
const result = h.loadPropAsset(s,'minecraft:carrot','right');
|
||||
assert.equal(result.parts.length,1);
|
||||
assert.equal(result.parts[0].generated,true);
|
||||
assert.deepEqual(Array.from(result.parts[0].transform.scale),[0.55,0.55,0.55]);
|
||||
assert.equal(result.parts[0].png,'data:image/png;base64,UE5H');
|
||||
});
|
||||
|
||||
test('left-hand and floor transforms follow Minecraft display contexts', () => {
|
||||
const h = harness(), model = {display:{thirdperson_righthand:{rotation:[10,20,30],translation:[1,2,3],scale:[.4,.5,.6]},ground:{scale:[.5,.5,.5]}}};
|
||||
const left = h.propDisplayTransform(model,'left');
|
||||
assert.deepEqual(Array.from(left.rotation),[10,-20,-30]);
|
||||
assert.deepEqual(Array.from(left.translation),[-1,2,3]);
|
||||
assert.deepEqual(Array.from(h.propDisplayTransform(model,'floor').scale),[.5,.5,.5]);
|
||||
const handheld = h.propDisplayTransform(h.manualPropModel('handheld'),'left');
|
||||
assert.deepEqual(Array.from(handheld.rotation),[0,-90,55]);
|
||||
assert.deepEqual(Array.from(handheld.translation),[0,4,0.5]);
|
||||
const explicit = h.propDisplayTransform({display:{thirdperson_lefthand:{rotation:[5,35,-20],translation:[2,3,4]}}},'left');
|
||||
assert.deepEqual(Array.from(explicit.rotation),[5,-35,20]);
|
||||
assert.deepEqual(Array.from(explicit.translation),[-2,3,4]);
|
||||
});
|
||||
|
||||
test('3D JSON models keep elements, UVs, rotations and texture aliases', () => {
|
||||
const h = harness(), s = source({
|
||||
'assets/minecraft/models/item/test.json': {textures:{all:'minecraft:block/stone'},elements:[{from:[0,0,0],to:[16,16,16],
|
||||
rotation:{origin:[8,8,8],axis:'y',angle:22.5},faces:{north:{texture:'#all',uv:[0,0,16,16]}}}]},
|
||||
'assets/minecraft/textures/block/stone.png':'image',
|
||||
});
|
||||
const result = h.loadPropAsset(s,'minecraft:test','floor');
|
||||
assert.equal(result.parts[0].elements[0].faces.north.texture,'minecraft:block/stone');
|
||||
assert.equal(result.parts[0].elements[0].rotation.angle,22.5);
|
||||
});
|
||||
|
||||
test('conditional appearances use fallback, special renderers fail clearly, cycles are rejected', () => {
|
||||
const h = harness(), entries = {
|
||||
'assets/minecraft/items/test.json':{model:{type:'minecraft:condition',on_false:{type:'minecraft:model',model:'minecraft:item/test'}}},
|
||||
'assets/minecraft/models/item/test.json':{parent:'builtin/generated',textures:{layer0:'minecraft:item/test'}},
|
||||
'assets/minecraft/textures/item/test.png':'image',
|
||||
};
|
||||
assert.match(h.loadPropAsset(source(entries),'minecraft:test','right').warnings[0],/default branch/);
|
||||
entries['assets/minecraft/items/test.json']={model:{type:'minecraft:special'}};
|
||||
assert.throws(() => h.loadPropAsset(source(entries),'minecraft:test','right'),/manual texture/);
|
||||
assert.throws(() => h.resolvePropModel(source({'assets/minecraft/models/item/a.json':{parent:'item/b'},
|
||||
'assets/minecraft/models/item/b.json':{parent:'item/a'}}),'item/a'),/Cyclic/);
|
||||
assert.throws(() => h.propTextureId('#a',{a:'#b',b:'#a'}),/Cyclic/);
|
||||
});
|
||||
|
||||
test('default props, stage replacements, hidden slots and inheritance preserve unrelated metadata', () => {
|
||||
const h = harness(), def = {actors:[{label:'player',injector:'V'},{label:'wolf'}],stages:[{stage:1},{stage:2,use_stage:1,props:null}]};
|
||||
const defaults = h.writePropDefinition(def,'player','right',0,'item','minecraft:carrot');
|
||||
assert.equal(defaults.actors[0].prop_right,'minecraft:carrot');
|
||||
assert.equal(def.actors[0].prop_right,undefined);
|
||||
const replace = h.writePropDefinition(defaults,'player','right',1,'item','minecraft:diamond_sword');
|
||||
assert.equal(replace.stages[0].props.player.prop_right,'minecraft:diamond_sword');
|
||||
const hidden = h.writePropDefinition(replace,'player','right',1,'hidden','');
|
||||
assert.equal(hidden.stages[0].props.player.prop_right,null);
|
||||
const inherit = h.writePropDefinition(hidden,'player','right',1,'inherit','');
|
||||
assert.equal(inherit.stages[0].props,undefined);
|
||||
const allHidden = h.writePropDefinition(defaults,'player','right',2,'item','minecraft:carrot');
|
||||
assert.equal(allHidden.stages[1].props.wolf,null);
|
||||
assert.equal(allHidden.stages[1].props.player.prop_left,null);
|
||||
assert.equal(allHidden.stages[1].use_stage,1);
|
||||
});
|
||||
|
||||
test('stage preview precedence supports hidden override and default fallback', () => {
|
||||
const h = harness(), entries = {};
|
||||
const base = {mode:'item'}, hidden = {mode:'hidden'};
|
||||
entries[h.propEntryKey('test','player','right',0)] = base;
|
||||
entries[h.propEntryKey('test','player','right',2)] = hidden;
|
||||
assert.equal(h.effectivePropEntry(entries,'test','player','right',1),base);
|
||||
assert.equal(h.effectivePropEntry(entries,'test','player','right',2),hidden);
|
||||
const def = {actors:[{label:'player',prop_right:'minecraft:carrot'}],stages:[{stage:1},
|
||||
{stage:2,props:{player:{prop_right:null}}},{stage:3,props:null},{stage:4,props:{player:{prop_right:'minecraft:stick'}}}]};
|
||||
assert.equal(h.effectivePropItem(def,'player','right',1),'minecraft:carrot');
|
||||
assert.equal(h.effectivePropItem(def,'player','right',2),null);
|
||||
assert.equal(h.effectivePropItem(def,'player','right',3),null);
|
||||
assert.equal(h.effectivePropItem(def,'player','right',4),'minecraft:stick');
|
||||
});
|
||||
|
||||
test('sprite extrusion preserves holes and omits internal boundary faces', () => {
|
||||
const h = harness(), pixels = new Uint8Array(3*3*4);
|
||||
for(let i=0;i<9;i++) pixels[i*4+3] = i === 4 ? 0 : 255;
|
||||
const elements = h.extrudePropPixels(3,3,pixels);
|
||||
assert.equal(elements.length,9);
|
||||
assert.equal(elements.slice(1).reduce((sum,e)=>sum+Object.keys(e.faces).length,0),16);
|
||||
assert.throws(() => h.extrudePropPixels(512,512,new Uint8Array()),/65,536/);
|
||||
});
|
||||
|
||||
test('asset discovery reuses Asset Browser cache without requiring the plugin', (t) => {
|
||||
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-cache-'));
|
||||
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
|
||||
fs.mkdirSync(path.join(root,'minecraft_assets_cache'));
|
||||
fs.writeFileSync(path.join(root,'minecraft_assets_cache','1.21.11.jar'),'jar');
|
||||
h.context.SystemInfo = {user_data_directory:root};
|
||||
const sources = h.discoverPropSources();
|
||||
assert.equal(sources.length,1);
|
||||
assert.equal(sources[0].label,'Asset Browser: 1.21.11');
|
||||
});
|
||||
|
||||
test('Item Props uses native dialog form fields and Apply/Cancel actions', () => {
|
||||
const h = harness();
|
||||
let configuration;
|
||||
h.context.Animation.selected = { name: 'test.p1' };
|
||||
h.context.SystemInfo = { user_data_directory: path.join(os.tmpdir(), 'missing-prop-assets') };
|
||||
h.context.Dialog = class {
|
||||
constructor(options) { configuration = options; }
|
||||
};
|
||||
h.createPropDialog();
|
||||
assert.equal(configuration.title,'Item Props');
|
||||
assert.equal(configuration.width,760);
|
||||
assert.deepEqual(Array.from(configuration.buttons),['Cancel','Apply prop']);
|
||||
assert.equal(configuration.component,undefined);
|
||||
assert.equal(configuration.form.item.type,'text');
|
||||
assert.equal(configuration.form.source.type,'select');
|
||||
assert.equal(configuration.form.jar.type,'file');
|
||||
assert.equal(configuration.form.folder.type,'folder');
|
||||
assert.equal(configuration.form.texture.type,'file');
|
||||
assert.equal(configuration.form.item_search,undefined);
|
||||
assert.equal(configuration.form.item_choice.type,'select');
|
||||
});
|
||||
|
||||
test('item dropdown includes the entire asset source alongside editable Item ID', (t) => {
|
||||
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-item-picker-'));
|
||||
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
|
||||
const items = path.join(root,'assets','minecraft','items');
|
||||
fs.mkdirSync(items,{recursive:true});
|
||||
for(let index=0;index<105;index++) fs.writeFileSync(path.join(items,`item_${String(index).padStart(3,'0')}.json`),'{}');
|
||||
h.context.Animation.selected = { name:'test.p1' };
|
||||
h.context.localStorage = { getItem() { return root; } };
|
||||
let configuration;
|
||||
h.context.Dialog = class {
|
||||
constructor(options){ configuration=options; }
|
||||
};
|
||||
h.createPropDialog();
|
||||
const options = configuration.form.item_choice.options();
|
||||
assert.equal(Object.keys(options).length,106);
|
||||
assert.equal(options['minecraft:item_104'],'minecraft:item_104');
|
||||
assert.equal(configuration.form.item.value,'minecraft:carrot');
|
||||
});
|
||||
|
||||
test('imported definitions create default and stage item previews without losing hidden overrides', (t) => {
|
||||
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-imported-props-'));
|
||||
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
|
||||
for (const item of ['carrot','stick']) {
|
||||
const write = (relative,data) => {
|
||||
const file=path.join(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true});
|
||||
fs.writeFileSync(file,typeof data==='string'?data:JSON.stringify(data));
|
||||
};
|
||||
write(`assets/minecraft/items/${item}.json`,{model:{type:'minecraft:model',model:`minecraft:item/${item}`}});
|
||||
write(`assets/minecraft/models/item/${item}.json`,{parent:'minecraft:builtin/generated',textures:{layer0:`minecraft:item/${item}`}});
|
||||
write(`assets/minecraft/textures/item/${item}.png`,'png bytes');
|
||||
}
|
||||
const definition={actors:[{label:'player',prop_right:'minecraft:carrot'}],stages:[
|
||||
{stage:1},{stage:2,props:{player:{prop_right:'minecraft:stick'}}},{stage:3,props:{player:{prop_right:null}}}]};
|
||||
const oldKey=h.propEntryKey('other','player','left',0);
|
||||
const result=h.importPropPreviews('example',definition,[root],{[oldKey]:{animation:'other'}});
|
||||
assert.equal(result.warnings.length,0);
|
||||
assert.equal(result.entries[oldKey].animation,'other');
|
||||
const get=(stage)=>result.entries[h.propEntryKey('example','player','right',stage)];
|
||||
assert.equal(get(0).item,'minecraft:carrot');
|
||||
assert.equal(get(0).asset.parts[0].generated,true);
|
||||
assert.equal(get(1),undefined);
|
||||
assert.equal(get(2).item,'minecraft:stick');
|
||||
assert.equal(get(3).mode,'hidden');
|
||||
const missing=h.importPropPreviews('example',{actors:[{label:'player',prop_left:'minecraft:missing'}],stages:[{stage:1}]},[root],result.entries);
|
||||
assert.match(missing.warnings[0],/minecraft:missing/);
|
||||
assert.equal(missing.entries[h.propEntryKey('example','player','left',0)],undefined);
|
||||
assert.equal(missing.entries[oldKey].animation,'other');
|
||||
});
|
||||
|
||||
module.exports = { harness };
|
||||
@@ -0,0 +1,166 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function harness() {
|
||||
// These tests exercise Z-axis rotations; Blockbench supplies full Three.js at runtime.
|
||||
class Quaternion {
|
||||
constructor() { this.angle = 0; }
|
||||
setFromEuler(euler) { this.angle = euler.z; return this; }
|
||||
multiply(other) { this.angle += other.angle; return this; }
|
||||
invert() { this.angle = -this.angle; return this; }
|
||||
toArray() { return [0, 0, Math.sin(this.angle / 2), Math.cos(this.angle / 2)]; }
|
||||
fromArray(values) { this.angle = 2 * Math.atan2(values[2], values[3]); return this; }
|
||||
}
|
||||
class Euler {
|
||||
constructor(x = 0, y = 0, z = 0) { Object.assign(this, { x, y, z }); }
|
||||
setFromQuaternion(q) { this.x = this.y = 0; this.z = q.angle; return this; }
|
||||
}
|
||||
class Vector3 {
|
||||
constructor(...values) { this.values = values; }
|
||||
sub(other) { this.values = this.values.map((v, i) => v - other.values[i]); return this; }
|
||||
add(other) { this.values = this.values.map((v, i) => v + other.values[i]); return this; }
|
||||
applyQuaternion(q) {
|
||||
const [x, y, z] = this.values, c = Math.cos(q.angle), s = Math.sin(q.angle);
|
||||
this.values = [x * c - y * s, x * s + y * c, z]; return this;
|
||||
}
|
||||
toArray() { return this.values; }
|
||||
}
|
||||
class Group {
|
||||
constructor(name, parent) { Object.assign(this, { name, origin: [1, 2, 3], rotation: [0, 0, 0], children: [], parent }); parent?.children.push(this); }
|
||||
}
|
||||
const h = { Group, Cube: class {}, THREE: { Quaternion, Euler, Vector3 },
|
||||
Format: { id: 'geckolib_model', euler_order: 'ZYX' }, Plugin: { register() {} } };
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {',
|
||||
' globalThis.api = { cemTemplateCatalog, jemPresetParts, jemTransferSuggestions, validateJemChoices, jemAdditionBones, addJemEnergyVisibility, refreshJemEnergyFields, jemTransferTransform, createJemTransferProject };\n Plugin.register(PLUGIN_ID, {');
|
||||
vm.runInNewContext(source, h);
|
||||
return h;
|
||||
}
|
||||
|
||||
test('JEM preset parent paths include attachment roots and nested groups', () => {
|
||||
const h = harness();
|
||||
const parts = h.api.jemPresetParts({ models: [{ part: 'body', submodels: [{ id: 'fur', submodels: [{ id: 'tip' }] }] }, { part: 'head' }] });
|
||||
assert.deepEqual(Array.from(parts, (part) => [part.name, part.path]), [['body', '0'], ['fur', '0/0'], ['tip', '0/0/0'], ['head', '1']]);
|
||||
});
|
||||
|
||||
test('JEM transfer compensates different parent rotations without rotating children twice', () => {
|
||||
const h = harness(), body = new h.Group('actor1_body'), extra = new h.Group('actor1_extra', body);
|
||||
body.rotation = [0, 0, 90]; extra.origin = [2, 2, 3]; extra.rotation = [0, 0, 10];
|
||||
const suggestions = h.api.jemTransferSuggestions([body, extra], [{ name: 'body', path: '0' }], 1);
|
||||
const addition = h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '0' })[0];
|
||||
const target = new h.Group('body'); target.origin = [10, 20, 30];
|
||||
const transform = h.api.jemTransferTransform(addition, target);
|
||||
transform.offset.forEach((value, axis) => assert.ok(Math.abs(value - [8, 19, 27][axis]) < 1e-8));
|
||||
assert.ok(Math.abs(transform.rotation[2] - 100) < 1e-8);
|
||||
assert.deepEqual(Array.from(addition.tree.rotation), [0, 0, 10]);
|
||||
});
|
||||
|
||||
test('JEM creation rescales GeckoLib UVs but preserves per-texture UVs in other formats', () => {
|
||||
for (const projectWide of [false, true]) {
|
||||
const h = harness(), cubes = [];
|
||||
h.Group.all = [];
|
||||
h.Group.prototype.init = function() { h.Group.all.push(this); return this; };
|
||||
h.Group.prototype.addTo = function(parent) { this.parent = parent; parent.children.push(this); return this; };
|
||||
h.Cube = class {
|
||||
constructor(data) { Object.assign(this, data); cubes.push(this); }
|
||||
addTo(parent) { parent.children.push(this); return this; }
|
||||
init() { return this; }
|
||||
};
|
||||
h.Texture = class {
|
||||
constructor(data) { Object.assign(this, data, { uuid: 'texture' }); }
|
||||
fromDataURL() { return this; }
|
||||
add() { return this; }
|
||||
};
|
||||
h.Project = {};
|
||||
h.Formats = { optifine_entity: { codec: { parse() { new h.Group('body').init(); } } } };
|
||||
h.newProject = () => true;
|
||||
h.Canvas = { updateAll() {} }; h.Blockbench = { showQuickMessage() {} };
|
||||
const addition = { target: '0', parentOrigin: [0, 0, 0], parentRotation: [0, 0, 0, 1], rotationOrder: 'ZYX',
|
||||
tree: { name: 'extra', origin: [0, 0, 0], rotation: [0, 0, 0], children: [{ cube: {
|
||||
name: 'cube', from: [0, 0, 0], to: [1, 1, 1], box_uv: false,
|
||||
faces: { north: { texture: 'old', uv: [0, 0, 16, 16] } } } }] } };
|
||||
h.api.createJemTransferProject({ name: 'test', model: { models: [] } }, [addition],
|
||||
[{ id: 'old', data: { uv_width: 32, uv_height: 32 }, image: 'mock' }], 'old',
|
||||
{ width: 64, height: 64, projectWide }, false);
|
||||
assert.deepEqual(Array.from(cubes[0].faces.north.uv), projectWide ? [0, 0, 8, 8] : [0, 0, 16, 16]);
|
||||
}
|
||||
});
|
||||
|
||||
test('suggests added subtrees once, strips actor prefixes and leaves vanilla hierarchy unchanged', () => {
|
||||
const h = harness(), root = new h.Group('actor1_root'), body = new h.Group('actor1_body', root);
|
||||
const d = new h.Group('actor1_d', body), tip = new h.Group('actor1_tip', d);
|
||||
const head = new h.Group('actor1_head', root), extra = new h.Group('actor1_extra', root);
|
||||
const suggestions = h.api.jemTransferSuggestions([root, body, d, tip, head, extra], [{ name: 'body', path: '0' }, { name: 'head', path: '1' }], 1);
|
||||
assert.deepEqual(Array.from(suggestions, (item) => [item.name, item.target]), [['d', '0'], ['extra', '']]);
|
||||
assert.equal(suggestions[0].root, d);
|
||||
assert.equal(tip.parent, d);
|
||||
assert.equal(body.name, 'actor1_body');
|
||||
});
|
||||
|
||||
test('selected additions require parent mapping; unchecked additions are omitted', () => {
|
||||
const h = harness(), body = new h.Group('actor1_body'), d = new h.Group('actor1_d', body);
|
||||
const suggestions = h.api.jemTransferSuggestions([body, d], [{ name: 'body', path: '0' }], 1);
|
||||
assert.throws(() => h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '' }), /Choose a target parent/);
|
||||
assert.throws(() => h.api.validateJemChoices(suggestions, { include_0: false }), /at least one/);
|
||||
const chosen = h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '0' });
|
||||
assert.equal(chosen[0].tree.name, 'd');
|
||||
assert.equal(chosen[0].target, '0');
|
||||
assert.deepEqual(Array.from(chosen[0].parentOrigin), [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('CEM catalog integration handles its initial undefined return and dependency errors', async () => {
|
||||
const h = harness();
|
||||
await assert.rejects(h.api.cemTemplateCatalog(), /Install and enable/);
|
||||
let calls = 0;
|
||||
const catalog = { models: { wolf: {} }, entities: [{ id: 'wolf' }] };
|
||||
h.window = { async loadCEMTemplateModels() { return ++calls === 1 ? undefined : catalog; } };
|
||||
assert.equal(await h.api.cemTemplateCatalog(), catalog);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('energy visibility is opt-in per transferred bone including nested subbones', () => {
|
||||
const h = harness(), body = new h.Group('actor1_body'), d = new h.Group('actor1_d', body);
|
||||
new h.Group('actor1_tip', d);
|
||||
const suggestions = h.api.jemTransferSuggestions([body, d, ...d.children], [{ name: 'body', path: '0' }], 1);
|
||||
assert.deepEqual(Array.from(h.api.jemAdditionBones(suggestions[0]), (bone) => [bone.name, bone.path]), [['d', 'root'], ['tip', 'root_0']]);
|
||||
const selected = h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '0', energy_0_root_0: true });
|
||||
assert.equal(selected[0].tree.energyVisibility, false);
|
||||
assert.equal(selected[0].tree.children[0].group.energyVisibility, true);
|
||||
});
|
||||
|
||||
test('energy expressions use the final unique bone name and preserve existing preset animations', () => {
|
||||
const h = harness(), root = new h.Group('body'), nested = new h.Group('addition', root);
|
||||
const tip = new h.Group('d3_2', nested);
|
||||
root.cem_animations = [{ 'body.rx': 'existing_expression' }];
|
||||
h.api.addJemEnergyVisibility(tip);
|
||||
assert.equal(root.cem_animations.length, 2);
|
||||
assert.equal(root.cem_animations[0]['body.rx'], 'existing_expression');
|
||||
assert.equal(root.cem_animations[1]['d3_2.visible'], 'nbt(NeedsOfNatureEnergy,range:70-200)');
|
||||
assert.equal(tip.cem_animations, undefined);
|
||||
});
|
||||
|
||||
test('parent energy visibility disables descendants and omits redundant child expressions without clearing their choices', () => {
|
||||
const h = harness(), body = new h.Group('actor1_body'), d = new h.Group('actor1_d', body);
|
||||
const tip = new h.Group('actor1_tip', d), end = new h.Group('actor1_end', tip);
|
||||
const suggestions = h.api.jemTransferSuggestions([body, d, tip, end], [{ name: 'body', path: '0' }], 1);
|
||||
const fields = Object.fromEntries(['root', 'root_0', 'root_0_0'].map((path) => {
|
||||
const input = {}, bar = { style: {}, querySelectorAll() { return [input]; } };
|
||||
return [`energy_0_${path}`, { bar, input }];
|
||||
}));
|
||||
const dialog = { form: { form_data: fields } };
|
||||
const result = { include_0: true, parent_0: '0', energy_0_root: true, energy_0_root_0: true, energy_0_root_0_0: true };
|
||||
h.api.refreshJemEnergyFields(dialog, suggestions, result);
|
||||
assert.equal(fields.energy_0_root.input.disabled, false);
|
||||
assert.equal(fields.energy_0_root_0.input.disabled, true);
|
||||
assert.equal(fields.energy_0_root_0_0.input.disabled, true);
|
||||
const tree = h.api.validateJemChoices(suggestions, result)[0].tree;
|
||||
assert.equal(tree.energyVisibility, true);
|
||||
assert.equal(tree.children[0].group.energyVisibility, false);
|
||||
result.energy_0_root = false;
|
||||
h.api.refreshJemEnergyFields(dialog, suggestions, result);
|
||||
assert.equal(fields.energy_0_root_0.input.disabled, false);
|
||||
assert.equal(fields.energy_0_root_0_0.input.disabled, true);
|
||||
assert.equal(h.api.validateJemChoices(suggestions, result)[0].tree.children[0].group.energyVisibility, true);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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');
|
||||
|
||||
test('retains all stage tracks when group removal deletes their animation bindings', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
|
||||
' Plugin.register(PLUGIN_ID, {',
|
||||
' globalThis.replacementTest = { captureActorAnimationBindings, removeActorGeometry, rebindActorAnimations };\n Plugin.register(PLUGIN_ID, {'
|
||||
);
|
||||
const animations = [];
|
||||
class Group {
|
||||
static all = [];
|
||||
constructor(name, uuid, parent = 'root') {
|
||||
Object.assign(this, { name, uuid, parent });
|
||||
Group.all.push(this);
|
||||
}
|
||||
remove() {
|
||||
for (const child of Group.all.filter((group) => group.parent === this)) child.remove();
|
||||
for (const animation of animations) animation.removeAnimator(this.uuid);
|
||||
Group.all = Group.all.filter((group) => group !== this);
|
||||
}
|
||||
}
|
||||
const root = new Group('actor1_body', 'old-body');
|
||||
new Group('actor1_head', 'old-head', root);
|
||||
new Group('actor1_removed', 'old-removed', root);
|
||||
new Group('actor2_body', 'other-body');
|
||||
for (let stage = 1; stage <= 2; stage++) {
|
||||
const animators = {};
|
||||
for (const group of Group.all) {
|
||||
const track = { uuid: group.uuid, _name: group.name, keyframes: [{ time: stage, rotation: [1, 2, 3] }] };
|
||||
Object.defineProperty(track, 'name', { get() { return this._name; } });
|
||||
animators[group.uuid] = track;
|
||||
}
|
||||
animations.push({ animators, saved: true, removeAnimator(uuid) { delete this.animators[uuid]; } });
|
||||
}
|
||||
const originalTracks = animations.map((animation) => ({ ...animation.animators }));
|
||||
const context = { Group, Animation: { all: animations }, Plugin: { register() {} }, console };
|
||||
vm.runInNewContext(source, context);
|
||||
const snapshot = context.replacementTest.captureActorAnimationBindings(1);
|
||||
context.replacementTest.removeActorGeometry(1);
|
||||
assert.equal(animations[0].animators['old-body'], undefined);
|
||||
new Group('actor1_body', 'new-body');
|
||||
new Group('actor1_head', 'new-head');
|
||||
const result = context.replacementTest.rebindActorAnimations(1, snapshot);
|
||||
assert.equal(result.rebound, 4);
|
||||
assert.equal(result.missing, 2);
|
||||
animations.forEach((animation, index) => {
|
||||
assert.equal(animation.animators['new-body'], originalTracks[index]['old-body']);
|
||||
assert.equal(animation.animators['new-head'], originalTracks[index]['old-head']);
|
||||
assert.equal(animation.animators['other-body'], originalTracks[index]['other-body']);
|
||||
assert.equal(animation.animators['old-removed'], originalTracks[index]['old-removed']);
|
||||
assert.equal(animation.animators['new-body'].uuid, 'new-body');
|
||||
assert.equal(animation.animators['old-body'], undefined);
|
||||
assert.equal(animation.saved, false);
|
||||
assert.deepEqual(animation.animators['new-body'].keyframes, [{ time: index + 1, rotation: [1, 2, 3] }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
|
||||
function harness() {
|
||||
const context = {
|
||||
Plugin: { register() {} }, Format: { id: 'geckolib_model' }, Project: {},
|
||||
Animation: { all: [], selected: null }, Group: { all: [] }, Timeline: { time: 0.7 },
|
||||
Animator: { resetLastValues() {}, preview() {} },
|
||||
edits: [], Undo: { initEdit(edit) { context.edits.push(edit); }, finishEdit(label) { context.label = label; } },
|
||||
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, 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, loop: 'loop', animators: {}, getBoneAnimator(group) {
|
||||
return this.animators[group.uuid] ||= animator();
|
||||
} };
|
||||
}
|
||||
|
||||
function animator() {
|
||||
return { position: [], rotation: [], scale: [], addKeyframe(data) {
|
||||
return this.createKeyframe(data, data.time, data.channel);
|
||||
}, createKeyframe(data, time, channel) {
|
||||
const frame = { ...data, time, channel, extend(data) { Object.assign(this, data); } };
|
||||
this[channel].push(frame); return frame;
|
||||
} };
|
||||
}
|
||||
|
||||
test('previous pose button stays visible and explains why it cannot be used', () => {
|
||||
const h = harness(), states = [], 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/);
|
||||
h.Animation.selected = animation('example.p1'); h.Animation.all = [h.Animation.selected];
|
||||
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', () => {
|
||||
const h = harness(), first = animation('example.p1'), current = animation('example.p3');
|
||||
h.Animation.selected = current;
|
||||
h.Animation.all = [animation('unrelated.p2'), current, first];
|
||||
h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 1 }, { stage: 2, use_stage: 1 }, { stage: 3 }] } });
|
||||
assert.equal(h.api.previousStagePoseSource().source, first);
|
||||
assert.equal(h.api.previousStagePoseSource().previous, 2);
|
||||
h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 2, use_stage: 9 }] } });
|
||||
assert.match(h.api.previousStagePoseSource().reason, /missing animation p9/);
|
||||
});
|
||||
|
||||
test('copies evaluated endpoint for all actors, resets absent channels and preserves later frames and effects', () => {
|
||||
const h = harness(), source = animation('example.p1'), target = animation('example.p2');
|
||||
h.Animation.selected = target; h.Animation.all = [target, source];
|
||||
const groups = [{ uuid: 'a', name: 'actor1_body' }, { uuid: 'b', name: 'actor2_head' }, { uuid: 'guide', name: '__ad_block_requirement_x' }];
|
||||
h.Group.all = groups;
|
||||
source.animators.a = animator();
|
||||
source.animators.a.position = [{ time: 1 }];
|
||||
source.animators.a.interpolate = (channel, allowExpression) => {
|
||||
assert.equal(h.Timeline.time, 2); assert.equal(allowExpression, false);
|
||||
return [4, 5, 6];
|
||||
};
|
||||
target.animators.a = animator();
|
||||
const initial = target.animators.a.createKeyframe({ data_points: [{ x: 99 }] }, 0, 'position');
|
||||
const later = target.animators.a.createKeyframe({ data_points: [{ x: 77 }] }, 1, 'position');
|
||||
target.animators.effects = { sound: [{ time: 0, effect: 'test' }] };
|
||||
h.api.copyPreviousStagePose();
|
||||
assert.equal(h.Timeline.time, 0.7);
|
||||
assert.equal(initial.data_points[0].x, 4);
|
||||
assert.equal(later.data_points[0].x, 77);
|
||||
assert.equal(target.animators.b.scale[0].data_points[0].x, 1);
|
||||
assert.equal(target.animators.b.rotation[0].data_points[0].x, 0);
|
||||
assert.equal(target.animators.effects.sound[0].effect, 'test');
|
||||
assert.equal(target.animators.guide, undefined);
|
||||
assert.equal(h.edits.length, 1);
|
||||
});
|
||||
|
||||
test('failed endpoint evaluation restores playhead and leaves target unmodified', () => {
|
||||
const h = harness(), source = animation('example.p1'), target = animation('example.p2');
|
||||
h.Animation.selected = target; h.Animation.all = [source, target];
|
||||
h.Group.all = [{ uuid: 'a', name: 'actor1_body' }];
|
||||
source.animators.a = { position: [{}], interpolate() { return [NaN, 0, 0]; } };
|
||||
assert.throws(() => h.api.copyPreviousStagePose(), /Cannot evaluate/);
|
||||
assert.equal(h.Timeline.time, 0.7);
|
||||
assert.equal(h.edits.length, 0);
|
||||
assert.deepEqual(target.animators, {});
|
||||
});
|
||||
|
||||
test('loop pose copies time zero to exact endpoint without modifying intermediate keys or source pose', () => {
|
||||
const h = harness(), clip = animation('example.p1', 2.1234);
|
||||
h.Animation.selected = clip; h.Animation.all = [clip];
|
||||
const group = { uuid: 'a', name: 'actor1_body' };
|
||||
h.Group.all = [group, { uuid: 'empty', name: 'actor2_head' }];
|
||||
const bone = clip.animators.a = animator();
|
||||
const first = bone.createKeyframe({ data_points: [{ x: 5, y: 6, z: 7 }] }, 0, 'position');
|
||||
const middle = bone.createKeyframe({ data_points: [{ x: 99 }] }, 1, 'position');
|
||||
const last = bone.createKeyframe({ data_points: [{ x: 88 }] }, clip.length, 'position');
|
||||
bone.interpolate = () => { assert.equal(h.Timeline.time, 0); return [5, 6, 7]; };
|
||||
h.api.copyFirstPoseToEnd();
|
||||
assert.equal(first.data_points[0].x, 5);
|
||||
assert.equal(middle.data_points[0].x, 99);
|
||||
assert.equal(last.data_points[0].x, 5);
|
||||
assert.equal(bone.scale.length, 0);
|
||||
assert.equal(bone.rotation.length, 0);
|
||||
assert.equal(clip.animators.empty, undefined);
|
||||
assert.equal(clip.length, 2.1234);
|
||||
assert.equal(h.Timeline.time, 0.7);
|
||||
assert.equal(h.edits.length, 1);
|
||||
});
|
||||
|
||||
test('loop pose on an unkeyed clip does not create animators or an undo edit', () => {
|
||||
const h = harness(), clip = animation('example.p1');
|
||||
h.Animation.selected = clip; h.Animation.all = [clip];
|
||||
h.Group.all = [{ uuid: 'a', name: 'actor1_body' }];
|
||||
h.api.copyFirstPoseToEnd();
|
||||
assert.deepEqual(clip.animators, {});
|
||||
assert.equal(h.edits.length, 0);
|
||||
});
|
||||
|
||||
test('loop pose is unavailable without a clip or with a zero length', () => {
|
||||
const h = harness();
|
||||
assert.match(h.api.loopPoseUnavailableReason(), /Select an animation/);
|
||||
h.Animation.selected = animation('example.p1', 0);
|
||||
h.api.copyFirstPoseToEnd();
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
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(), blobs = [], revoked = [], undo = [];
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
|
||||
.replace(' Plugin.register(PLUGIN_ID, {', `
|
||||
globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds,
|
||||
applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY,
|
||||
installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper, refreshSoundLibraryPanel };
|
||||
Plugin.register(PLUGIN_ID, {`);
|
||||
class EffectAnimator {
|
||||
constructor(animation) { this.animation = animation; this.sound = []; }
|
||||
createKeyframe(data, time) {
|
||||
const frame = { channel: 'sound', time, animator: this, select() {},
|
||||
extend(data) { this.data_points = data.data_points.map((point) => ({ ...point, getUndoCopy() { return { ...this }; } })); } };
|
||||
frame.extend(data); this.sound.push(frame); return frame;
|
||||
}
|
||||
select() {}
|
||||
}
|
||||
const context = {
|
||||
Plugin: { register() {} }, Buffer, requireNativeModule: require, Blob,
|
||||
URL: { createObjectURL(blob) { blobs.push(blob); return `blob:test-${blobs.length}`; }, revokeObjectURL(url) { revoked.push(url); } },
|
||||
localStorage: { getItem(key) { return storage.get(key); }, setItem(key, value) { storage.set(key, value); } },
|
||||
Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] },
|
||||
Undo: { initEdit(aspects) { undo.push(aspects.keyframes.slice()); }, finishEdit() {} },
|
||||
Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} },
|
||||
Format: { id: 'geckolib_model' }, settings: { volume: { value: 80 } }, console,
|
||||
played: [], coreCalls: [],
|
||||
Audio: class {
|
||||
constructor(file) { this.file = file; }
|
||||
play() { context.played.push(this); return Promise.resolve(); }
|
||||
pause() { this.paused = true; }
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(source, context);
|
||||
return { ...context.api, context, storage, blobs, revoked, undo };
|
||||
}
|
||||
|
||||
function brokenFigura(h) {
|
||||
vm.runInNewContext(`(() => {
|
||||
const format = { id: 'figura' };
|
||||
const displayFrame = function (in_loop) { coreCalls.push(in_loop); return 'original-result'; };
|
||||
EffectAnimator.prototype.displayFrame = function () {
|
||||
if (Format === format) return;
|
||||
displayFrame.call(this);
|
||||
};
|
||||
})()`, h.context);
|
||||
const animation = { time: 0, length: 2, animators: {}, playing: true };
|
||||
const effects = new h.context.EffectAnimator(animation);
|
||||
effects.muted = { sound: false };
|
||||
effects.sound = [{ uuid: 'cue', time: 0.5, data_points: [{ file: 'manual.ogg' }] }];
|
||||
animation.animators.effects = effects;
|
||||
h.context.Animation.all = [animation];
|
||||
Object.assign(h.context.Timeline, { playing: true, playback_speed: 100, custom_range: [0, 0], playing_sounds: [] });
|
||||
return { animation, effects, original: h.context.EffectAnimator.prototype.displayFrame };
|
||||
}
|
||||
|
||||
function assets(t, json, files = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-sounds-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const write = (name, content) => { const file = path.join(root, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); };
|
||||
write('assets/needsofnature/sounds.json', JSON.stringify(json));
|
||||
for (const [name, content] of Object.entries(files)) write(`assets/needsofnature/sounds/${name}.ogg`, content);
|
||||
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'));
|
||||
const library = h.loadSoundLibrary();
|
||||
assert(library.events.has('needsofnature:spray'));
|
||||
assert(library.events.size > 60);
|
||||
assert.equal(h.blobs.length, 0);
|
||||
const audio = h.resolveSoundAudio('reactiveimpact');
|
||||
assert.equal(audio, h.resolveSoundAudio('needsofnature:impactdry01'));
|
||||
assert.equal(h.blobs.length, 1);
|
||||
assert.equal(h.resolveSoundAudio('reactiveimpact_silent'), null);
|
||||
assert.equal(h.resolveSoundAudio('not_a_sound'), null);
|
||||
h.loadSoundLibrary();
|
||||
assert(h.revoked.includes(audio));
|
||||
});
|
||||
|
||||
test('event references and random pools resolve safely; cyclic or missing events stay unresolved', (t) => {
|
||||
const h = harness();
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, {
|
||||
one: { sounds: ['needsofnature:one'] },
|
||||
nested: { sounds: [{ type: 'event', name: 'needsofnature:one' }] },
|
||||
loop: { sounds: [{ type: 'event', name: 'needsofnature:loop' }] },
|
||||
unsafe: { sounds: ['needsofnature:../outside'] },
|
||||
}, { one: 'OggS test' }));
|
||||
h.loadSoundLibrary();
|
||||
assert.equal(h.resolveSoundAudio('nested'), h.resolveSoundAudio('one'));
|
||||
assert.equal(h.resolveSoundAudio('random(needsofnature:missing, needsofnature:one)'), h.resolveSoundAudio('one'));
|
||||
assert.equal(h.resolveSoundAudio('loop'), null);
|
||||
assert.equal(h.resolveSoundAudio('unsafe'), null);
|
||||
});
|
||||
|
||||
test('pack sound priority honors replace without overriding higher-priority entries', (t) => {
|
||||
const h = harness();
|
||||
const high = assets(t, { one: { replace: true, sounds: ['needsofnature:high'] } }, { high: 'high' });
|
||||
const low = assets(t, { one: { sounds: ['needsofnature:low'] } }, { low: 'low' });
|
||||
h.storage.set('animationdirector_pack_folders', JSON.stringify([high]));
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, low);
|
||||
const library = h.loadSoundLibrary();
|
||||
assert.deepEqual(Array.from(library.events.get('needsofnature:one').entries), ['needsofnature:high']);
|
||||
assert(h.resolveSoundAudio('one'));
|
||||
});
|
||||
|
||||
test('mapping retains cue IDs and manual previews, updates managed files and handles missing/silent cues', (t) => {
|
||||
const h = harness();
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
|
||||
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
|
||||
h.loadSoundLibrary();
|
||||
const points = [{ effect: 'reactiveimpact' }, { effect: 'reactiveimpact', file: 'custom.ogg' },
|
||||
{ effect: 'unknown' }, { effect: 'reactiveimpact_silent' }];
|
||||
const animation = { animators: { effects: { sound: [{ data_points: points }] } } };
|
||||
h.context.Animation.all = [animation];
|
||||
h.mapAnimationSounds([animation]);
|
||||
const original = points[0].file;
|
||||
assert(original.startsWith('blob:'));
|
||||
assert.equal(points[0].effect, 'reactiveimpact');
|
||||
assert.equal(points[1].file, 'custom.ogg');
|
||||
assert.equal(points[2].file, '');
|
||||
assert.equal(points[3].file, '');
|
||||
h.loadSoundLibrary();
|
||||
h.mapAnimationSounds([animation]);
|
||||
assert.notEqual(points[0].file, original);
|
||||
points[0].effect = 'reactiveimpact_silent';
|
||||
h.mapAnimationSounds([animation]);
|
||||
assert.equal(points[0].file, '');
|
||||
});
|
||||
|
||||
test('reactive impact uses saved peak-stage metadata separately for each animation stage', (t) => {
|
||||
const h = harness();
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
|
||||
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
|
||||
const library = h.loadSoundLibrary();
|
||||
h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: {
|
||||
stages: [{ stage: 1 }, { stage: 2, non_peak: true }],
|
||||
} }) };
|
||||
const normal = { name: 'test.p1', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' }] }] } } };
|
||||
const peaked = { name: 'test.p2', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' },
|
||||
{ effect: 'reactiveimpact', file: 'manual.ogg' }, { effect: 'reactiveimpact_silent' }] }] } } };
|
||||
h.mapAnimationSounds([normal, peaked]);
|
||||
assert.equal(normal.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01'));
|
||||
assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('shot_in01'));
|
||||
assert.equal(peaked.animators.effects.sound[0].data_points[1].file, 'manual.ogg');
|
||||
assert.equal(peaked.animators.effects.sound[0].data_points[2].file, '');
|
||||
h.context.Animation.selected = peaked;
|
||||
h.showSoundLibrary();
|
||||
h.context.dialog.component.methods.preview.call({ selected: 'reactiveimpact', error: '' });
|
||||
assert.equal(h.context.played[0].file, h.resolveSoundAudio('shot_in01'));
|
||||
h.context.Project.animationdirector_animdef_definitions = JSON.stringify({ test: { stages: [{ stage: 2, non_peak: false }] } });
|
||||
h.mapAnimationSounds([peaked]);
|
||||
assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01'));
|
||||
assert.equal(h.resolveSoundAudio('random(needsofnature:reactiveimpact)', library, new Set(), normal), h.resolveSoundAudio('impactdry01'));
|
||||
});
|
||||
|
||||
test('missing stage metadata defaults to a normal impact; reused peak stages do not change the source stage', (t) => {
|
||||
const h = harness();
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
|
||||
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
|
||||
const library = h.loadSoundLibrary();
|
||||
h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: {
|
||||
stages: [{ stage: 1 }, { stage: 2, use_stage: 1, non_peak: true }],
|
||||
} }) };
|
||||
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p1' }), h.resolveSoundAudio('impactdry01'));
|
||||
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p2' }), h.resolveSoundAudio('shot_in01'));
|
||||
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'other.p3' }), h.resolveSoundAudio('impactdry01'));
|
||||
});
|
||||
|
||||
test('insertion is undoable and preserves existing cues at the same time; Apply edits selection', (t) => {
|
||||
const h = harness();
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { one: { sounds: ['needsofnature:one'] } }, { one: 'one' }));
|
||||
h.loadSoundLibrary();
|
||||
h.applyLibrarySound('one', true);
|
||||
const animation = h.context.Animation.selected, effects = animation.animators.effects;
|
||||
assert.equal(effects.sound.length, 1);
|
||||
assert.equal(effects.sound[0].time, 2);
|
||||
assert(effects.sound[0].data_points[0].file.startsWith('blob:'));
|
||||
h.applyLibrarySound('birth', true);
|
||||
assert.equal(effects.sound.length, 1);
|
||||
assert.equal(effects.sound[0].data_points.length, 2);
|
||||
assert.equal(effects.sound[0].data_points[0].effect, 'one');
|
||||
h.context.Timeline.selected = [effects.sound[0]];
|
||||
h.applyLibrarySound('reactiveimpact_silent');
|
||||
assert.equal(effects.sound[0].data_points[0].effect, 'reactiveimpact_silent');
|
||||
assert.equal(effects.sound[0].data_points[0].file, '');
|
||||
assert.equal(h.undo.length, 3);
|
||||
});
|
||||
|
||||
test('editor preview fields never appear in exported sound cues', () => {
|
||||
const h = harness();
|
||||
const clip = { sound_effects: { '0.0': [{ effect: 'reactiveimpact', file: 'blob:test',
|
||||
[h.SOUND_PREVIEW_PROPERTY]: 'blob:test', volume: 0.5 }, { effect: 'custom:sound' }] } };
|
||||
const result = h.compileConjoinedStage({ name: 'sample.p1', animation: { compileBedrockAnimation() { return clip; } } });
|
||||
assert.equal(result.clip.sound_effects['0.0'][0].effect, 'reactiveimpact');
|
||||
assert.equal(result.clip.sound_effects['0.0'][0].volume, 0.5);
|
||||
assert.equal(result.clip.sound_effects['0.0'][0].file, undefined);
|
||||
assert.equal(clip.sound_effects['0.0'][0].file, 'blob:test');
|
||||
});
|
||||
|
||||
test('library UI auditions on a single click without reactive preview controls', () => {
|
||||
const h = harness();
|
||||
h.showSoundLibrary();
|
||||
const ui = h.context.dialog.component;
|
||||
assert.match(ui.template, /Search sound IDs/);
|
||||
assert.match(ui.template, /Insert at Playhead/);
|
||||
assert.doesNotMatch(ui.template, /Reactive preview|@dblclick/);
|
||||
assert.match(ui.template, /@click="selected=row.id;preview\(\)"/);
|
||||
assert.equal(typeof ui.methods.preview, 'function');
|
||||
assert.equal(typeof ui.methods.reload, 'function');
|
||||
});
|
||||
|
||||
test('sound selection hides blacklisted events without disabling existing cue previews', () => {
|
||||
const h = harness();
|
||||
h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources'));
|
||||
h.showSoundLibrary();
|
||||
const ui = h.context.dialog.component;
|
||||
const state = ui.data();
|
||||
assert(state.ids.includes('needsofnature:a'));
|
||||
assert(state.ids.includes('needsofnature:funkytown'));
|
||||
for (const query of ['', 'needsofnature:a', 'funkytown']) {
|
||||
const rows = ui.computed.rows.call({ ...state, query });
|
||||
assert(!rows.some((row) => row.id === 'needsofnature:a' || row.id === 'needsofnature:funkytown'));
|
||||
}
|
||||
assert(h.resolveSoundAudio('needsofnature:a'));
|
||||
assert(h.resolveSoundAudio('needsofnature:funkytown'));
|
||||
});
|
||||
|
||||
test('Figura workaround detects only the known faulty wrapper and restores it on unload', () => {
|
||||
const h = harness(), f = brokenFigura(h);
|
||||
assert(h.isBrokenFiguraSoundWrapper(f.original));
|
||||
h.installFiguraSoundCompat();
|
||||
const patched = h.context.EffectAnimator.prototype.displayFrame;
|
||||
assert.notEqual(patched, f.original);
|
||||
h.installFiguraSoundCompat();
|
||||
assert.equal(h.context.EffectAnimator.prototype.displayFrame, patched);
|
||||
f.animation.time = 0.5;
|
||||
f.effects.displayFrame(true);
|
||||
h.removeFiguraSoundCompat();
|
||||
assert.equal(h.context.EffectAnimator.prototype.displayFrame, f.original);
|
||||
for (const corrected of [function (in_loop) {}, function (...args) { return args; }]) {
|
||||
h.context.EffectAnimator.prototype.displayFrame = corrected;
|
||||
h.installFiguraSoundCompat();
|
||||
assert.equal(h.context.EffectAnimator.prototype.displayFrame, corrected);
|
||||
}
|
||||
});
|
||||
|
||||
test('Figura sound workaround triggers crossed keyframes even beyond the native 33ms window', () => {
|
||||
const h = harness(), f = brokenFigura(h);
|
||||
h.beginFiguraSoundPlayback();
|
||||
f.animation.time = 0.45; f.effects.displayFrame(true);
|
||||
f.animation.time = 0.6; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 1);
|
||||
assert.equal(h.context.played[0].file, 'manual.ogg');
|
||||
assert.equal(h.context.played[0].volume, 0.8);
|
||||
assert.equal(h.context.coreCalls.length, 2);
|
||||
assert.equal(h.context.coreCalls[0], undefined);
|
||||
f.animation.time = 0.7; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 1);
|
||||
});
|
||||
|
||||
test('Figura workaround plays start cues and restarts long sounds on subsequent loops', () => {
|
||||
const h = harness(), f = brokenFigura(h);
|
||||
f.effects.sound[0].time = 0;
|
||||
h.beginFiguraSoundPlayback();
|
||||
f.animation.time = 0.04; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 1);
|
||||
f.animation.time = 1.9; f.effects.displayFrame(true);
|
||||
f.animation.time = 0; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 2);
|
||||
assert(h.context.played[0].paused);
|
||||
assert.equal(h.context.Timeline.playing_sounds.length, 1);
|
||||
h.removeFiguraSoundCompat();
|
||||
assert(h.context.played[1].paused);
|
||||
assert.equal(h.context.Timeline.playing_sounds.length, 0);
|
||||
});
|
||||
|
||||
test('Figura workaround respects sound mute, scrubbing, other formats and resumed native audio', () => {
|
||||
const h = harness(), f = brokenFigura(h);
|
||||
h.beginFiguraSoundPlayback();
|
||||
f.animation.time = 0.7; f.effects.displayFrame(false);
|
||||
assert.equal(h.context.played.length, 0);
|
||||
f.animation.time = 0; h.beginFiguraSoundPlayback();
|
||||
f.effects.muted.sound = true;
|
||||
f.animation.time = 0.6; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 0);
|
||||
f.effects.muted.sound = false;
|
||||
f.animation.time = 0; h.beginFiguraSoundPlayback();
|
||||
h.context.Format.id = 'figura';
|
||||
f.animation.time = 0.6; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 0);
|
||||
h.context.Format.id = 'geckolib_model';
|
||||
f.animation.time = 0; h.beginFiguraSoundPlayback();
|
||||
h.context.Timeline.playing_sounds.push({ keyframe_id: 'cue' });
|
||||
f.animation.time = 0.6; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 0);
|
||||
});
|
||||
|
||||
test('Figura workaround honors custom loop ranges and leaves later plugin patches installed', () => {
|
||||
const h = harness(), f = brokenFigura(h);
|
||||
Object.assign(h.context.Timeline, { custom_range: [0.3, 1] });
|
||||
f.effects.sound = [{ uuid: 'before', time: 0.1, data_points: [{ file: 'excluded.ogg' }] },
|
||||
{ uuid: 'start', time: 0.3, data_points: [{ file: 'included.ogg' }] },
|
||||
{ uuid: 'after', time: 1.2, data_points: [{ file: 'excluded.ogg' }] }];
|
||||
f.animation.time = 0.3; h.beginFiguraSoundPlayback();
|
||||
f.animation.time = 0.35; f.effects.displayFrame(true);
|
||||
f.animation.time = 0.95; f.effects.displayFrame(true);
|
||||
f.animation.time = 0.3; f.effects.displayFrame(true);
|
||||
assert.deepEqual(h.context.played.map((media) => media.file), ['included.ogg', 'included.ogg']);
|
||||
const patched = h.context.EffectAnimator.prototype.displayFrame;
|
||||
const later = function (...args) { return patched.apply(this, args); };
|
||||
h.context.EffectAnimator.prototype.displayFrame = later;
|
||||
h.removeFiguraSoundCompat();
|
||||
assert.equal(h.context.EffectAnimator.prototype.displayFrame, later);
|
||||
f.animation.time = 0.95; f.effects.displayFrame(true);
|
||||
f.animation.time = 0.3; f.effects.displayFrame(true);
|
||||
assert.equal(h.context.played.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function harness() {
|
||||
const h = { Plugin: { register() {} }, Animation: { selected: null, all: [] }, Format: { id: 'geckolib_model' },
|
||||
Animator: { preview() { h.rendered = h.Animation.selected; } },
|
||||
Timeline: { playing: true, custom_range: [0, 0], loop() { this.playing = false; }, setTime(time) { this.time = time; }, start() { this.playing = true; this.loop(); } } };
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {',
|
||||
' globalThis.api = { installStageAutoplay, removeStageAutoplay, nextAutoplayStage, setEnabled(value) { autoplayStagesAction = { value }; } };\n Plugin.register(PLUGIN_ID, {');
|
||||
vm.runInNewContext(source, h);
|
||||
h.clip = (name, loop = 'once') => ({ name, length: 2, loop, select() { h.Animation.selected = this; } });
|
||||
h.api.setEnabled(true); h.api.installStageAutoplay();
|
||||
return h;
|
||||
}
|
||||
|
||||
test('autoplay advances completed non-looping stages by number within the same ID', () => {
|
||||
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
|
||||
h.Animation.selected = first; h.Animation.all = [h.clip('other.p2'), h.clip('example.p10'), next, first];
|
||||
h.Timeline.loop();
|
||||
assert.equal(h.Animation.selected, next);
|
||||
assert.equal(h.Timeline.time, 0);
|
||||
assert.equal(h.Timeline.playing, true);
|
||||
assert.equal(h.rendered, next);
|
||||
});
|
||||
|
||||
test('autoplay leaves looping stages, custom ranges, disabled toggle and final stage alone', () => {
|
||||
for (const mode of ['loop', 'range', 'disabled', 'final']) {
|
||||
const h = harness(), first = h.clip('example.p1', mode === 'loop' ? 'loop' : 'hold');
|
||||
h.Animation.selected = first; h.Animation.all = mode === 'final' ? [first] : [first, h.clip('example.p2')];
|
||||
if (mode === 'range') h.Timeline.custom_range = [0, 1];
|
||||
if (mode === 'disabled') h.api.setEnabled(false);
|
||||
h.Timeline.loop();
|
||||
assert.equal(h.pending, undefined);
|
||||
assert.equal(h.Animation.selected, first);
|
||||
}
|
||||
});
|
||||
|
||||
test('manual playback stops do not advance and unload restores playback', () => {
|
||||
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
|
||||
h.Animation.selected = first; h.Animation.all = [first, next];
|
||||
h.Timeline.playing = false; h.Timeline.loop();
|
||||
assert.equal(h.pending, undefined);
|
||||
assert.equal(h.Animation.selected, first);
|
||||
assert.equal(h.Timeline.playing, false);
|
||||
const wrapper = h.Timeline.loop; h.api.removeStageAutoplay();
|
||||
assert.notEqual(h.Timeline.loop, wrapper);
|
||||
});
|
||||
|
||||
test('autoplay overrides global looping for once stages and restores it even on failure', () => {
|
||||
const h = harness(); h.api.removeStageAutoplay();
|
||||
h.BarItems = { looped_animation_playback: { value: true } };
|
||||
h.Timeline.loop = function() { if (!h.BarItems.looped_animation_playback.value) this.playing = false; };
|
||||
const first = h.clip('example.p1'), next = h.clip('example.p2');
|
||||
h.Animation.selected = first; h.Animation.all = [first, next];
|
||||
h.api.installStageAutoplay(); h.Timeline.loop();
|
||||
assert.equal(h.Animation.selected, next);
|
||||
assert.equal(h.BarItems.looped_animation_playback.value, true);
|
||||
h.api.removeStageAutoplay();
|
||||
h.Timeline.loop = () => { throw new Error('preview failed'); };
|
||||
h.api.installStageAutoplay();
|
||||
assert.throws(() => h.Timeline.loop(), /preview failed/);
|
||||
assert.equal(h.BarItems.looped_animation_playback.value, true);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
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, restoreActorTextureMaterial };\n Plugin.register(PLUGIN_ID, {'
|
||||
);
|
||||
class Group {
|
||||
static all = [];
|
||||
constructor(data) { Object.assign(this, data); }
|
||||
init() { Group.all.push(this); return this; }
|
||||
addTo(parent) { this.parent = parent; return this; }
|
||||
}
|
||||
const cubes = [];
|
||||
class Cube {
|
||||
static all = cubes;
|
||||
constructor(name, box = false) {
|
||||
Object.assign(this, typeof name === 'object' ? name : { name });
|
||||
this.box_uv = box;
|
||||
this.faces = { north: { uv: [0, 0, 16, 16] } };
|
||||
this.preview_controller = { updateUV(cube) {
|
||||
if (!cube.box_uv || !cube.uv_offset || !cube.from || !cube.to) return;
|
||||
const sizeX = cube.to[0] - cube.from[0], sizeY = cube.to[1] - cube.from[1];
|
||||
const sizeZ = cube.to[2] - cube.from[2];
|
||||
const [u, v] = cube.uv_offset;
|
||||
cube.faces.north.uv = [u + sizeZ, v + sizeZ, u + sizeZ + sizeX, v + sizeZ + sizeY];
|
||||
} };
|
||||
cubes.push(this);
|
||||
}
|
||||
init() { return this; }
|
||||
addTo(parent) { this.parent = parent; return this; }
|
||||
setUVMode(box) { this.box_uv = box; }
|
||||
}
|
||||
class Texture {
|
||||
static all = [];
|
||||
constructor(data) { Object.assign(this, data); this.uuid = data.name; }
|
||||
fromDataURL(url) { this.source = url; return this; }
|
||||
add() { Texture.all.push(this); return this; }
|
||||
}
|
||||
const context = { Buffer, requireNativeModule: require, Group, Cube, Texture, console, Format: { id: 'geckolib_model' },
|
||||
setTimeout(callback) { callback(); },
|
||||
Undo: { initEdit() {}, finishEdit() {}, cancelEdit() {} },
|
||||
Project: { texture_width: 64, texture_height: 64, box_uv: true },
|
||||
Canvas: { updateAllUVs() {}, updateAll() {} }, Plugin: { register() {} },
|
||||
localStorage: { getItem() { return null; } },
|
||||
};
|
||||
vm.runInNewContext(source, context);
|
||||
return { ...context.uvTest, context, Cube, Texture };
|
||||
}
|
||||
|
||||
test('growing the project UV canvas preserves other actors and converts box UVs', () => {
|
||||
const h = harness(), actor1 = new h.Cube('actor1_body', false), actor2 = new h.Cube('actor2_body', true);
|
||||
h.normalizeActorUvs(1, 64, 64);
|
||||
h.normalizeActorUvs(2, 64, 64);
|
||||
h.normalizeActorUvs(1, 128, 64);
|
||||
assert.equal(h.context.Project.texture_width, 128);
|
||||
assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 16, 16]);
|
||||
assert.deepEqual(Array.from(actor2.faces.north.uv), [0, 0, 32, 16]);
|
||||
assert.equal(actor2.box_uv, false);
|
||||
h.normalizeActorUvs(1, 32, 64);
|
||||
assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 64, 16]);
|
||||
assert.deepEqual(Array.from(actor2.faces.north.uv), [0, 0, 32, 16]);
|
||||
h.normalizeActorUvs(1, 32, 64);
|
||||
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);
|
||||
h.context.Format.id = format;
|
||||
const before = JSON.stringify(cube.faces.north.uv);
|
||||
const texture = new h.Texture({ name: 'custom.png', uv_width: 128, uv_height: 128 });
|
||||
texture.add();
|
||||
assert.equal(h.expandProjectUvs(128, 128), false);
|
||||
assert.equal(h.normalizeActorUvs(1, 128, 128), false);
|
||||
h.scaleCubeUvs(cube, 2, 2);
|
||||
h.applyActorTexturePreview(1, 'custom.png');
|
||||
assert.equal(JSON.stringify(cube.faces.north.uv), before);
|
||||
assert.equal(cube.faces.north.texture, texture.uuid);
|
||||
assert.equal(cube.box_uv, true);
|
||||
assert.equal(h.context.Project.texture_width, 64);
|
||||
assert.equal(h.context.Project.texture_height, 64);
|
||||
assert.equal(h.context.Project.animationdirector_actor_uv_basis, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('assigning a manually imported texture to an actor normalizes only that actor once', () => {
|
||||
const h = harness(), selected = new h.Cube('actor1_body'), other = new h.Cube('actor2_body');
|
||||
h.normalizeActorUvs(1, 64, 64); h.normalizeActorUvs(2, 64, 64);
|
||||
const texture = new h.Texture({ name: 'custom.png' });
|
||||
texture.img = { naturalWidth: 32, naturalHeight: 64 };
|
||||
texture.add();
|
||||
h.applyActorTexturePreview(1, 'custom.png');
|
||||
assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]);
|
||||
assert.deepEqual(Array.from(other.faces.north.uv), [0, 0, 16, 16]);
|
||||
h.applyActorTexturePreview(1, 'custom.png');
|
||||
assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]);
|
||||
});
|
||||
|
||||
test('bone texture dimensions override the base without repeated UV scaling', () => {
|
||||
const h = harness();
|
||||
const group = new h.context.Group({ name: 'actor1_wind', uuid: 'wind' });
|
||||
group.init();
|
||||
const baseCube = new h.Cube('actor1_base'), windCube = new h.Cube('actor1_wind_cube');
|
||||
windCube.addTo(group);
|
||||
h.context.Project.texture_width = h.context.Project.texture_height = 128;
|
||||
h.normalizeActorUvs(1, 128, 128);
|
||||
for (const [name, size] of [['vanilla', 32], ['wind', 128]]) {
|
||||
new h.Texture({ name, uv_width: size, uv_height: size }).add();
|
||||
}
|
||||
h.setProjectActorTextures({ 1: 'vanilla' });
|
||||
h.setProjectBoneTextureOverrides({ wind: { uuid: 'wind', name: group.name, texture: 'wind' } });
|
||||
h.applyAllActorTexturePreviews();
|
||||
assert.deepEqual(Array.from(baseCube.faces.north.uv), [0, 0, 64, 64]);
|
||||
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]);
|
||||
assert.equal(windCube.faces.north.texture, 'wind');
|
||||
h.applyAllActorTexturePreviews();
|
||||
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]);
|
||||
h.setProjectBoneTextureOverrides({});
|
||||
h.applyAllActorTexturePreviews();
|
||||
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 64, 64]);
|
||||
});
|
||||
|
||||
test('importing a larger actor preserves UV mapping of the earlier model', () => {
|
||||
const h = harness();
|
||||
const geometry = (width) => ({ description: { texture_width: width, texture_height: width }, bones: [{ name: 'body', cubes: [
|
||||
{ origin: [0, 0, 0], size: [2, 2, 2], uv: { north: { uv: [0, 0], uv_size: [16, 16] } } },
|
||||
] }] });
|
||||
h.importGeometryIntoCurrentProject(geometry(64), 1, false, false);
|
||||
h.importGeometryIntoCurrentProject(geometry(128), 2, false, false);
|
||||
assert.equal(h.context.Project.texture_width, 128);
|
||||
assert.deepEqual(Array.from(h.Cube.all[0].faces.north.uv), [0, 0, 32, 32]);
|
||||
assert.deepEqual(Array.from(h.Cube.all[1].faces.north.uv), [0, 0, 16, 16]);
|
||||
assert.equal(h.getActorUvBasis()['1'].width, 64);
|
||||
assert.equal(h.getActorUvBasis()['2'].width, 128);
|
||||
});
|
||||
|
||||
test('box UV import resolves face coordinates before converting to a smaller texture', () => {
|
||||
const h = harness();
|
||||
h.importGeometryIntoCurrentProject({ description: { texture_width: 32, texture_height: 32 }, bones: [
|
||||
{ name: 'body', cubes: [{ origin: [0, 0, 0], size: [2, 3, 4], uv: [8, 4] }] },
|
||||
] }, 1, false, false);
|
||||
const cube = h.Cube.all[0];
|
||||
assert.equal(cube.box_uv, false);
|
||||
assert.deepEqual(Array.from(cube.faces.north.uv), [24, 16, 28, 22]);
|
||||
});
|
||||
|
||||
test('box UV import keeps its original coordinates when no scaling is needed', () => {
|
||||
const h = harness();
|
||||
h.importGeometryIntoCurrentProject({ description: { texture_width: 64, texture_height: 64 }, bones: [
|
||||
{ name: 'body', cubes: [{ origin: [0, 0, 0], size: [2, 3, 4], uv: [8, 4] }] },
|
||||
] }, 1, false, false);
|
||||
const cube = h.Cube.all[0];
|
||||
assert.equal(cube.box_uv, true);
|
||||
assert.deepEqual(Array.from(cube.faces.north.uv), [12, 8, 14, 11]);
|
||||
});
|
||||
|
||||
test('manual model import can load an explicitly referenced texture from its extracted pack', (t) => {
|
||||
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-uv-pack-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const write = (relative, contents) => { const file = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, contents); return file; };
|
||||
write('pack.mcmeta', '{}');
|
||||
const model = write('assets/example/geckolib/models/entity/demo.geo.json', '{}');
|
||||
const png = Buffer.alloc(24); Buffer.from('89504e470d0a1a0a', 'hex').copy(png); png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20);
|
||||
write('assets/example/textures/entity/demo.png', png);
|
||||
new h.Cube('actor1_body');
|
||||
h.normalizeActorUvs(1, 64, 64);
|
||||
h.tryLoadImportedActorTexture({ afw_texture: 'example:textures/entity/demo.png' }, {}, model, 1);
|
||||
assert.equal(h.Texture.all.length, 1);
|
||||
assert.equal(h.Texture.all[0].relative_path, 'assets/example/textures/entity/demo.png');
|
||||
assert.equal(JSON.parse(h.context.Project.multiactor_actor_textures)['1'], h.Texture.all[0].name);
|
||||
assert.equal(h.getActorUvBasis()['1'].width, 32);
|
||||
});
|
||||
Reference in New Issue
Block a user