copy last stage button

This commit is contained in:
2026-09-29 13:19:39 +02:00
parent 3489315611
commit 94b3145254
2 changed files with 106 additions and 13 deletions
+101 -7
View File
@@ -19,6 +19,8 @@
let importAnimationDefinitionAction; let importAnimationDefinitionAction;
let automaticImportAction; let automaticImportAction;
let packFoldersAction; let packFoldersAction;
let previousStagePoseAction;
const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation'];
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews'; const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' }; const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
let propAction, propProjectProperty, propDialog, propListener; let propAction, propProjectProperty, propDialog, propListener;
@@ -2467,6 +2469,76 @@
return { groups, ignored }; return { groups, ignored };
} }
function previousStagePoseSource() {
if (typeof Format === 'undefined' || Format.id !== 'geckolib_model') return { reason: 'Open a GeckoLib animation project first.' };
const current = parseAfwStageAnimationName(Animation.selected);
if (!current) return { reason: 'Select an animation stage named animationID.pN.' };
const stages = getSavedAnimdef(current.id).stages || [];
const candidates = [...Animation.all.map(parseAfwStageAnimationName).filter((stage) => stage?.id === current.id),
...stages.map((stage) => ({ stageNumber: stage.stage }))].filter((stage) => stage.stageNumber < current.stageNumber);
if (!candidates.length) return { reason: 'This animation has no previous stage.' };
const previous = Math.max(...candidates.map((stage) => stage.stageNumber));
const playback = stages.find((stage) => stage.stage === previous)?.use_stage ?? previous;
const matches = Animation.all.filter((animation) => {
const stage = parseAfwStageAnimationName(animation);
return stage?.id === current.id && stage.stageNumber === playback;
});
if (matches.length !== 1) return { reason: matches.length ? 'The previous stage has duplicate animations.' : `Previous stage p${previous} uses missing animation p${playback}.` };
if (matches[0] === Animation.selected) return { reason: 'The previous stage reuses the current animation.' };
return { source: matches[0], previous };
}
function refreshPreviousStagePoseButton() {
if (!previousStagePoseAction) return;
const { reason } = previousStagePoseSource();
const description = reason || 'Copy the final transforms of all actors from the previous stage into time 0. Existing time-0 transforms are replaced.';
previousStagePoseAction.description = description;
for (const node of previousStagePoseAction.nodes || []) {
node.classList.toggle('disabled', !!reason);
node.setAttribute('aria-disabled', String(!!reason));
const label = node.querySelector('.tooltip_description');
if (label) label.textContent = description;
}
}
function copyPreviousStagePose() {
const { source, reason } = previousStagePoseSource();
if (reason) { Blockbench.showQuickMessage(reason, 2500); return; }
const target = Animation.selected, poses = [];
const originalTime = Timeline.time;
try {
Timeline.time = source.length;
for (const group of Group.all) {
if (readActorIndexFromName(group.name) === null || isBlockRequirementGuide(group)) continue;
const animator = source.animators[group.uuid];
Animator.resetLastValues();
for (const channel of ['position', 'rotation', 'scale']) {
const value = animator?.[channel]?.length ? animator.interpolate(channel, false) : null;
const values = value || (channel === 'scale' ? [1, 1, 1] : [0, 0, 0]);
if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error(`Cannot evaluate ${group.name} ${channel} at the previous stage endpoint.`);
poses.push({ group, channel, values });
}
}
} finally {
Timeline.time = originalTime;
Animator.resetLastValues();
}
if (!poses.length) { Blockbench.showQuickMessage('There are no actor bones to copy.', 2500); return; }
const keyframes = poses.flatMap(({ group, channel }) => (target.animators[group.uuid]?.[channel] || []).filter((frame) => frame.time === 0));
Undo.initEdit({ animations: [target], keyframes });
for (const { group, channel, values } of poses) {
const animator = target.getBoneAnimator(group);
const frame = animator[channel].find((frame) => frame.time === 0);
const data = { interpolation: 'linear', uniform: false, data_points: [{ x: values[0], y: values[1], z: values[2] }] };
if (frame) frame.extend(data);
else keyframes.push(animator.createKeyframe(data, 0, channel, false, false));
}
Undo.finishEdit('Copy previous stage pose', { animations: [target], keyframes });
Animator.preview();
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
Blockbench.showQuickMessage('Copied previous stage pose to time 0.', 2500);
}
function cleanSoundPreviewFields(clip) { function cleanSoundPreviewFields(clip) {
for (const cues of Object.values(clip?.sound_effects || {})) { for (const cues of Object.values(clip?.sound_effects || {})) {
for (const cue of Array.isArray(cues) ? cues : [cues]) { for (const cue of Array.isArray(cues) ? cues : [cues]) {
@@ -5715,6 +5787,22 @@ You can import or export complete AFW animation IDs as one file containing all s
version: '2.0.0', version: '2.0.0',
variant: 'both', variant: 'both',
onload() { onload() {
previousStagePoseAction = new Action('animationdirector_previous_stage_pose', {
name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation',
description: 'Copy the final actor pose of the previous stage into time 0.',
click() {
try { copyPreviousStagePose(); }
catch (error) { Blockbench.showMessageBox({ title: 'Copy Previous Stage Pose', message: error.message }); }
},
});
if (typeof Toolbars !== 'undefined' && Toolbars.timeline) {
const toolbar = Toolbars.timeline;
const marker = toolbar.children.findIndex((item) => item === 'add_marker' || item?.id === 'add_marker');
toolbar.add(previousStagePoseAction, marker >= 0 ? marker + 1 : 0);
}
for (const event of POSE_BUTTON_EVENTS) Blockbench.on(event, refreshPreviousStagePoseButton);
previousStagePoseAction.node?.addEventListener('mouseenter', refreshPreviousStagePoseButton);
refreshPreviousStagePoseButton();
if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') { if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') {
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, { soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
exposed: false, condition: (point) => point.keyframe.channel === 'sound', exposed: false, condition: (point) => point.keyframe.channel === 'sound',
@@ -5833,7 +5921,7 @@ You can import or export complete AFW animation IDs as one file containing all s
replaceActorAction = new Action('replace_actor_gecko_json', { replaceActorAction = new Action('replace_actor_gecko_json', {
name: 'Replace Actor Model', name: 'Replace Actor Model',
description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.', description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.',
icon: 'published_with_changes', icon: 'swap_horiz',
click: replaceActorGeckoJson, click: replaceActorGeckoJson,
}); });
@@ -5847,7 +5935,7 @@ You can import or export complete AFW animation IDs as one file containing all s
actorPreferencesAction = new Action('edit_actor_preferences', { actorPreferencesAction = new Action('edit_actor_preferences', {
name: 'Actor Preferences', name: 'Actor Preferences',
description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.', description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.',
icon: 'badge', icon: 'psychology',
click: editActorPreferences, click: editActorPreferences,
}); });
@@ -5882,11 +5970,11 @@ You can import or export complete AFW animation IDs as one file containing all s
importAnimationDefinitionAction = new Action('import_afw_animation_definition', { importAnimationDefinitionAction = new Action('import_afw_animation_definition', {
name: 'Import Animation Definition', name: 'Import Animation Definition',
description: 'Load generator fields and block requirement guides from an existing animdef JSON.', description: 'Load generator fields and block requirement guides from an existing animdef JSON.',
icon: 'file_open', icon: 'assignment_add',
click: importAnimationDefinition, click: importAnimationDefinition,
}); });
automaticImportAction = new Action('animationdirector_automatic_import', { automaticImportAction = new Action('animationdirector_automatic_import', {
name: 'Automatically Import Animation', icon: 'auto_awesome', name: 'Automatically Import Animation', icon: 'add_box',
description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.', description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.',
click: automaticallyImportAnimation, click: automaticallyImportAnimation,
}); });
@@ -5912,14 +6000,18 @@ You can import or export complete AFW animation IDs as one file containing all s
icon: 'movie', icon: 'movie',
children: [ children: [
automaticImportAction, automaticImportAction,
{ id: 'animationdirector_manual_import', name: 'Manual Import', icon: 'file_open', children: [ importAction,
importAction, importConjoinedAnimationAction, importAnimationDefinitionAction, packFoldersAction, importConjoinedAnimationAction,
] }, importAnimationDefinitionAction,
packFoldersAction,
'_',
actorPreferencesAction, actorPreferencesAction,
replaceActorAction, replaceActorAction,
'_',
blockRequirementAction, blockRequirementAction,
propAction, propAction,
soundLibraryAction, soundLibraryAction,
'_',
createAnimationDefinitionAction, createAnimationDefinitionAction,
exportConjoinedAction, exportConjoinedAction,
], ],
@@ -5934,6 +6026,8 @@ You can import or export complete AFW animation IDs as one file containing all s
MenuBar.update(); MenuBar.update();
}, },
onunload() { onunload() {
for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton);
previousStagePoseAction?.delete(); previousStagePoseAction = undefined;
Blockbench.removeListener('timeline_play', figuraSoundPlayListener); Blockbench.removeListener('timeline_play', figuraSoundPlayListener);
removeFiguraSoundCompat(); removeFiguraSoundCompat();
for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener); for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener);
+5 -6
View File
@@ -105,12 +105,11 @@ test('animation menu orders import and creation actions and has no deprecated sp
const h = harness(); const h = harness();
h.context.registration.onload(); h.context.registration.onload();
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims'); const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
assert.deepEqual(Array.from(menu.children, (action) => action.name), [ assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [
'Automatically Import Animation', 'Manual Import', 'Actor Preferences', 'Automatically Import Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
'Replace Actor Model', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'Create Animation Definition', 'Export Animations', '_', 'Actor Preferences', 'Replace Actor Model',
]); '_', 'Block Requirements', 'Item Props', 'NoN Sound Library',
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [ '_', 'Create Animation Definition', 'Export Animations',
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
]); ]);
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8'); const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/); assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);