copy last stage button
This commit is contained in:
+101
-7
@@ -19,6 +19,8 @@
|
||||
let importAnimationDefinitionAction;
|
||||
let automaticImportAction;
|
||||
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_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
|
||||
let propAction, propProjectProperty, propDialog, propListener;
|
||||
@@ -2467,6 +2469,76 @@
|
||||
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) {
|
||||
for (const cues of Object.values(clip?.sound_effects || {})) {
|
||||
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',
|
||||
variant: 'both',
|
||||
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') {
|
||||
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
|
||||
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', {
|
||||
name: 'Replace Actor Model',
|
||||
description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.',
|
||||
icon: 'published_with_changes',
|
||||
icon: 'swap_horiz',
|
||||
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', {
|
||||
name: 'Actor Preferences',
|
||||
description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.',
|
||||
icon: 'badge',
|
||||
icon: 'psychology',
|
||||
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', {
|
||||
name: 'Import Animation Definition',
|
||||
description: 'Load generator fields and block requirement guides from an existing animdef JSON.',
|
||||
icon: 'file_open',
|
||||
icon: 'assignment_add',
|
||||
click: importAnimationDefinition,
|
||||
});
|
||||
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.',
|
||||
click: automaticallyImportAnimation,
|
||||
});
|
||||
@@ -5912,14 +6000,18 @@ You can import or export complete AFW animation IDs as one file containing all s
|
||||
icon: 'movie',
|
||||
children: [
|
||||
automaticImportAction,
|
||||
{ id: 'animationdirector_manual_import', name: 'Manual Import', icon: 'file_open', children: [
|
||||
importAction, importConjoinedAnimationAction, importAnimationDefinitionAction, packFoldersAction,
|
||||
] },
|
||||
importAction,
|
||||
importConjoinedAnimationAction,
|
||||
importAnimationDefinitionAction,
|
||||
packFoldersAction,
|
||||
'_',
|
||||
actorPreferencesAction,
|
||||
replaceActorAction,
|
||||
'_',
|
||||
blockRequirementAction,
|
||||
propAction,
|
||||
soundLibraryAction,
|
||||
'_',
|
||||
createAnimationDefinitionAction,
|
||||
exportConjoinedAction,
|
||||
],
|
||||
@@ -5934,6 +6026,8 @@ You can import or export complete AFW animation IDs as one file containing all s
|
||||
MenuBar.update();
|
||||
},
|
||||
onunload() {
|
||||
for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton);
|
||||
previousStagePoseAction?.delete(); previousStagePoseAction = undefined;
|
||||
Blockbench.removeListener('timeline_play', figuraSoundPlayListener);
|
||||
removeFiguraSoundCompat();
|
||||
for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener);
|
||||
|
||||
@@ -105,12 +105,11 @@ test('animation menu orders import and creation actions and has no deprecated sp
|
||||
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.name), [
|
||||
'Automatically Import Animation', 'Manual Import', 'Actor Preferences',
|
||||
'Replace Actor Model', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'Create Animation Definition', 'Export Animations',
|
||||
]);
|
||||
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [
|
||||
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||
assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [
|
||||
'Automatically Import Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||
'_', 'Actor Preferences', 'Replace Actor Model',
|
||||
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library',
|
||||
'_', 'Create Animation Definition', 'Export Animations',
|
||||
]);
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);
|
||||
|
||||
Reference in New Issue
Block a user