added way of replacing actor models

This commit is contained in:
2026-09-28 18:40:30 +02:00
parent a5e72e1d74
commit f1f597dcca
2 changed files with 224 additions and 5 deletions
+165 -5
View File
@@ -17,7 +17,7 @@
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 actorTexturePreviewProjectListener;
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
// ----------------------------
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 : [];
if (!bones.length) {
Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500);
@@ -562,8 +626,11 @@
}
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();
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();
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() {
@@ -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
// ----------------------------
@@ -3538,6 +3689,13 @@ You can import or export complete AFW animation IDs as one file containing all s
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', {
name: 'Import 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,
importConjoinedAnimationAction,
actorPreferencesAction,
replaceActorAction,
exportConjoinedAction,
createAnimationDefinitionAction,
{
@@ -3655,6 +3814,7 @@ You can import or export complete AFW animation IDs as one file containing all s
actorPreferencesAction.delete();
boneTextureOverridesAction.delete();
exportGeckoModelAction.delete();
replaceActorAction.delete();
exportSelectedAction.delete();
exportAllAction.delete();
exportConjoinedAction.delete();
+59
View File
@@ -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] }]);
});
});