added way of replacing actor models
This commit is contained in:
+165
-5
@@ -17,7 +17,7 @@
|
|||||||
other: 'Other',
|
other: 'Other',
|
||||||
};
|
};
|
||||||
|
|
||||||
let importAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction;
|
let importAction, replaceActorAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction;
|
||||||
let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty;
|
let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty;
|
||||||
let actorTexturePreviewProjectListener;
|
let actorTexturePreviewProjectListener;
|
||||||
const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path'];
|
const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path'];
|
||||||
@@ -554,7 +554,71 @@
|
|||||||
// ----------------------------
|
// ----------------------------
|
||||||
// Import Actor Geometry
|
// Import Actor Geometry
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
function importGeometryIntoCurrentProject(geo, actorIndex) {
|
function removeActorGeometry(actorIndex) {
|
||||||
|
const prefix = `actor${actorIndex}_`;
|
||||||
|
const roots = Group.all.filter((group) => {
|
||||||
|
if (!group?.name?.startsWith(prefix)) return false;
|
||||||
|
return !(group.parent instanceof Group && group.parent.name?.startsWith(prefix));
|
||||||
|
});
|
||||||
|
for (const root of roots) root.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureActorAnimationBindings(actorIndex) {
|
||||||
|
const prefix = `actor${actorIndex}_`;
|
||||||
|
const bindings = new Map();
|
||||||
|
for (const group of Group.all || []) {
|
||||||
|
if (group?.name?.startsWith(prefix)) bindings.set(group.name, group.uuid);
|
||||||
|
}
|
||||||
|
// Removing a group deletes its animators, so retain the tracks before removal.
|
||||||
|
const animations = typeof Animation !== 'undefined' && Array.isArray(Animation.all) ? Animation.all : [];
|
||||||
|
return animations.map((animation) => ({
|
||||||
|
animation,
|
||||||
|
tracks: Array.from(bindings, ([boneName, uuid]) => ({ boneName, uuid, animator: animation.animators?.[uuid] }))
|
||||||
|
.filter((track) => track.animator),
|
||||||
|
})).filter((entry) => entry.tracks.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebindActorAnimations(actorIndex, previousBindings) {
|
||||||
|
if (!previousBindings?.length) {
|
||||||
|
return { rebound: 0, missing: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `actor${actorIndex}_`;
|
||||||
|
const replacementGroups = new Map();
|
||||||
|
for (const group of Group.all || []) {
|
||||||
|
if (group?.name?.startsWith(prefix)) replacementGroups.set(group.name, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rebound = 0;
|
||||||
|
let missing = 0;
|
||||||
|
for (const { animation, tracks } of previousBindings) {
|
||||||
|
const animators = animation.animators;
|
||||||
|
if (!animators || typeof animators !== 'object') continue;
|
||||||
|
|
||||||
|
for (const { boneName, uuid: oldUuid, animator } of tracks) {
|
||||||
|
const replacement = replacementGroups.get(boneName);
|
||||||
|
if (!replacement) {
|
||||||
|
animator._name = boneName;
|
||||||
|
animators[oldUuid] = animator;
|
||||||
|
animation.saved = false;
|
||||||
|
missing++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof animation.removeAnimator === 'function') animation.removeAnimator(oldUuid);
|
||||||
|
else delete animators[oldUuid];
|
||||||
|
animator.uuid = replacement.uuid;
|
||||||
|
animator._name = replacement.name;
|
||||||
|
animators[replacement.uuid] = animator;
|
||||||
|
animation.saved = false;
|
||||||
|
rebound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rebound, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false) {
|
||||||
const bones = Array.isArray(geo?.bones) ? geo.bones : [];
|
const bones = Array.isArray(geo?.bones) ? geo.bones : [];
|
||||||
if (!bones.length) {
|
if (!bones.length) {
|
||||||
Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500);
|
Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500);
|
||||||
@@ -562,8 +626,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prefix = `actor${actorIndex}_`;
|
const prefix = `actor${actorIndex}_`;
|
||||||
|
const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null;
|
||||||
|
|
||||||
Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], animations: [] });
|
Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [],
|
||||||
|
animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] });
|
||||||
|
if (replaceExisting) removeActorGeometry(actorIndex);
|
||||||
|
|
||||||
const boneByName = new Map();
|
const boneByName = new Map();
|
||||||
for (const b of bones) if (b?.name) boneByName.set(b.name, b);
|
for (const b of bones) if (b?.name) boneByName.set(b.name, b);
|
||||||
@@ -644,11 +711,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const animationResult = replaceExisting
|
||||||
|
? rebindActorAnimations(actorIndex, previousBindings)
|
||||||
|
: { rebound: 0, missing: 0 };
|
||||||
|
|
||||||
Canvas.updateAll();
|
Canvas.updateAll();
|
||||||
applyAllActorTexturePreviews(false);
|
applyAllActorTexturePreviews(false);
|
||||||
Undo.finishEdit(`Import Actor ${actorIndex}`);
|
if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select();
|
||||||
|
Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`);
|
||||||
|
|
||||||
Blockbench.showQuickMessage(`Imported actor with prefix "${prefix}"`, 2500);
|
const animationNotice = replaceExisting && animationResult.rebound
|
||||||
|
? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}`
|
||||||
|
: '';
|
||||||
|
Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000);
|
||||||
|
|
||||||
|
if (replaceExisting && animationResult.missing) {
|
||||||
|
Blockbench.showMessageBox({
|
||||||
|
title: 'Replacement Model Missing Animated Bones',
|
||||||
|
message: `${animationResult.missing} existing animation channel${animationResult.missing === 1 ? '' : 's'} could not be rebound because the replacement model does not contain the matching actor bone. Those channels were left unchanged.`,
|
||||||
|
buttons: ['OK'],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function importActorGeckoJson() {
|
function importActorGeckoJson() {
|
||||||
@@ -687,6 +770,74 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceActorGeckoJson() {
|
||||||
|
if (!Project) {
|
||||||
|
Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorOptions = buildActorSelectOptions();
|
||||||
|
if (!Object.keys(actorOptions).length) {
|
||||||
|
Blockbench.showQuickMessage('No actorN_ model found in this project.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Dialog({
|
||||||
|
id: 'replace_actor_gecko_json',
|
||||||
|
title: 'Replace Actor Model',
|
||||||
|
width: 520,
|
||||||
|
form: {
|
||||||
|
actor: {
|
||||||
|
label: 'Actor to replace',
|
||||||
|
type: 'select',
|
||||||
|
options: actorOptions,
|
||||||
|
value: Object.keys(actorOptions)[0],
|
||||||
|
},
|
||||||
|
_info: {
|
||||||
|
type: 'info',
|
||||||
|
text: 'The selected actor model will be replaced. Its actor prefix, animations, and actor preferences are preserved.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
buttons: ['Cancel', 'Choose Model'],
|
||||||
|
cancelIndex: 0,
|
||||||
|
confirmIndex: 1,
|
||||||
|
onConfirm: (result) => {
|
||||||
|
const actorIndex = Number(result?.actor);
|
||||||
|
if (!Number.isFinite(actorIndex) || actorIndex <= 0) {
|
||||||
|
Blockbench.showQuickMessage('Choose a valid actor.', 2500);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Filesystem.importFile(
|
||||||
|
{
|
||||||
|
type: 'GeckoLib / Bedrock Geometry JSON',
|
||||||
|
extensions: ['json', 'geo.json'],
|
||||||
|
multiple: false,
|
||||||
|
readtype: 'text',
|
||||||
|
resource_id: 'model',
|
||||||
|
title: 'Select replacement actor model',
|
||||||
|
},
|
||||||
|
(files) => {
|
||||||
|
if (!files?.length) return;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(files[0]?.content || '');
|
||||||
|
const geo = extractGeckoGeometryRoot(json);
|
||||||
|
if (!geo) {
|
||||||
|
Blockbench.showQuickMessage('Unsupported JSON structure (expected minecraft:geometry[0]).', 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importGeometryIntoCurrentProject(geo, actorIndex, true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Blockbench.showQuickMessage(`Replacement failed: ${e?.message || e}`, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
// Actor Preferences
|
// Actor Preferences
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
@@ -3538,6 +3689,13 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
click: importActorGeckoJson,
|
click: importActorGeckoJson,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
replaceActorAction = new Action('replace_actor_gecko_json', {
|
||||||
|
name: 'Replace Actor Model',
|
||||||
|
description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.',
|
||||||
|
icon: 'published_with_changes',
|
||||||
|
click: replaceActorGeckoJson,
|
||||||
|
});
|
||||||
|
|
||||||
importConjoinedAnimationAction = new Action('import_conjoined_actor_animation', {
|
importConjoinedAnimationAction = new Action('import_conjoined_actor_animation', {
|
||||||
name: 'Import Animation',
|
name: 'Import Animation',
|
||||||
description: 'Import one conjoined AnimationDirector JSON and rebuild every stage and actor animation.',
|
description: 'Import one conjoined AnimationDirector JSON and rebuild every stage and actor animation.',
|
||||||
@@ -3620,6 +3778,7 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
importAction,
|
importAction,
|
||||||
importConjoinedAnimationAction,
|
importConjoinedAnimationAction,
|
||||||
actorPreferencesAction,
|
actorPreferencesAction,
|
||||||
|
replaceActorAction,
|
||||||
exportConjoinedAction,
|
exportConjoinedAction,
|
||||||
createAnimationDefinitionAction,
|
createAnimationDefinitionAction,
|
||||||
{
|
{
|
||||||
@@ -3655,6 +3814,7 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
actorPreferencesAction.delete();
|
actorPreferencesAction.delete();
|
||||||
boneTextureOverridesAction.delete();
|
boneTextureOverridesAction.delete();
|
||||||
exportGeckoModelAction.delete();
|
exportGeckoModelAction.delete();
|
||||||
|
replaceActorAction.delete();
|
||||||
exportSelectedAction.delete();
|
exportSelectedAction.delete();
|
||||||
exportAllAction.delete();
|
exportAllAction.delete();
|
||||||
exportConjoinedAction.delete();
|
exportConjoinedAction.delete();
|
||||||
|
|||||||
@@ -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] }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user